Skip to content

Read only the named stream for Guid.Empty, an empty key and a null key, and refuse them in CompactStreamAsync - #5695

Merged
jeremydmiller merged 1 commit into
JasperFx:masterfrom
lahma:fix/empty-stream
Oct 19, 2026
Merged

jeremydmiller merged 1 commit into
JasperFx:masterfrom
lahma:fix/empty-stream

Conversation

@lahma

@lahma lahma commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Problem

CompactStreamAsync<T>(Guid.Empty) compacts the whole store. Take a store with two ordinary streams and no Guid.Empty stream at all. Compacting Guid.Empty rewrites the newest event in the store as Compacted<T>, although that event belongs to another stream, and deletes every other event, all in one transaction. On master the other stream's three events are simply gone:

failed ...compacting_the_empty_guid_leaves_every_other_stream_alone
  first.Select(x => x.Data.GetType())
      should be [AEvent, BEvent, AEvent]
      but was []

CompactStreamAsync<T>("") and CompactStreamAsync<T>(null) do the same on a string-identified store.

The reads underneath are wrong in the same way, for Guid.Empty, "" and a null key alike:

  • FetchStreamAsync and the batched FetchStream return every non-archived event of the tenant, or of the whole store when it is single-tenanted.
  • AggregateStreamAsync<T>, and the live plan's FetchLatest<T> from a session or a batch, fold all of those events into one aggregate.

Root cause

EventStatement.filters() emits the stream predicate only if (StreamId != Guid.Empty), and only for StreamKey.IsNotEmpty(). All eight places that construct an EventStatement set exactly one of the two, so nothing relies on "empty means no filter". It only falls out of the property defaults: the string overloads leave StreamId at Guid.Empty.

Marten refuses these values on Append and StartStream. They still reach these reads in two ways:

  • An unset id in calling code. This reaches every read above, with no such stream existing at all.
  • A stream stored under Guid.Empty. BulkInsertEventsAsync writes one: StreamAction.Start does not guard the id, and neither does the bulk path. The test uses that path. By reading EventBoundary and BulkEventAppender, a DCB boundary routing on an empty tag value and legacy or hand-written SQL can leave one behind too. I have not tested those two.

Fix

The rule is a set value is a stream.

  • EventStatement.StreamId becomes Guid?. A string key is tracked as assigned by its setter rather than read as non-empty.
  • Guid.Empty and "" read that stream: its events if one exists, and nothing otherwise. That matches what every read already does for an id nobody wrote, and what FetchStreamStateAsync(Guid.Empty) already does (where id = ?, unconditionally).
  • A null key names no stream, because stream_id is never null, so it reads nothing. It emits false, the predicate Marten's LINQ already uses for a constant false.

Both CompactStreamAsync overloads refuse Guid.Empty and a null or empty key with ArgumentOutOfRangeException, using the messages Append and StartStream already use. With the read fixed, compacting a real Guid.Empty stream would only touch that stream. But compaction is the one API here that deletes events, so it should not depend on a read predicate for its reach. Every other write already refuses these ids.

Why reads filter rather than throw. A read of an id nobody wrote already returns an empty list. Throwing would turn GET /streams/{id} with a defaulted id into a 500 instead of an empty result, and the behaviour that existed was a data leak rather than a contract anyone could rely on. For a null key specifically, nothing in Marten refuses one with ArgumentNullException today, so a read that returns nothing matches existing behaviour best. As measured on this branch:

  • FetchStreamStateAsync((string)null) fails incidentally, in Npgsql: InvalidOperationException: Parameter '' cannot be null, DBNull.Value should be used instead.
  • FetchForWriting<T> and FetchForExclusiveWriting<T> with a null key throw the start-stream guard's ArgumentOutOfRangeException. With an expected version they throw ConcurrencyException.
  • Append(null) throws ArgumentOutOfRangeException.

FetchForWriting<T> needs no change for any of these ids, on either path: the session's (BuildEventQueryHandler(bool, string, IEventStorage, …)) or the batch's (BuildEventQueryHandler(bool, string, ISqlFragment?)). Once it has read, it goes through StartStream/Append, which throw. Before this change it read the whole tenant first. A test pins the refusal on both paths.

Only EventStatement and the two compaction entry points change. The store-level IEventStore.CompactStreamAsync reaches the session-level overloads, so it is covered as well.

Tests

src/EventSourcingTests/empty_stream_identity_is_not_a_wildcard.cs has 22 cases:

  • Guid store: FetchStreamAsync with and without a stream stored under Guid.Empty, the batched FetchStream, AggregateStreamAsync and FetchLatest.
  • String store, each for "" and null: FetchStreamAsync, the batched FetchStream, AggregateStreamAsync, FetchLatest from the session and from a batch, and FetchForWriting from the session and from a batch.
  • Compaction: refusing Guid.Empty (with and without such a stream), "" and null.

The data-loss assertions run before the refusal assertion, so a regression reads as the lost events rather than as a missing exception.

Red on master (2ece3a9)

20 of 22 fail. The 2 that pass are the FetchForWriting pins, which hold before and after.

failed ...fetching_the_empty_guid_reads_nothing_when_there_is_no_such_stream
  await query.Events.FetchStreamAsync(Guid.Empty) should be empty but had 5 items and was
  [Event<AEvent>, Event<BEvent>, Event<AEvent>, Event<AEvent>, Event<CEvent>]
failed ...fetching_the_empty_guid_reads_only_that_stream
  events.Count should be 2 but was 7
failed ...the_batched_fetch_of_the_empty_guid_reads_nothing_when_there_is_no_such_stream
  await fetch should be empty but had 5 items
failed ...aggregating_the_empty_guid_does_not_fold_the_whole_store
  await query.Events.AggregateStreamAsync<Letters>(Guid.Empty) should be null but was EventSourcingTests.Aggregation.Letters
failed ...fetching_the_latest_of_the_empty_guid_does_not_fold_the_whole_store
  await session.Events.FetchLatest<Letters>(Guid.Empty) should be null but was EventSourcingTests.Aggregation.Letters
failed ...fetching_an_empty_or_null_key_reads_nothing(streamKey: "")
failed ...fetching_an_empty_or_null_key_reads_nothing(streamKey: null)
  await query.Events.FetchStreamAsync(streamKey!) should be empty but had 5 items
failed ...the_batched_fetch_of_an_empty_or_null_key_reads_nothing(streamKey: "")
failed ...the_batched_fetch_of_an_empty_or_null_key_reads_nothing(streamKey: null)
  await fetch should be empty but had 5 items
failed ...aggregating_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: "")
failed ...aggregating_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: null)
  await query.Events.AggregateStreamAsync<KeyedLetters>(streamKey!) should be null but was EventSourcingTests.KeyedLetters
failed ...fetching_the_latest_of_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: "")
failed ...fetching_the_latest_of_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: null)
  await session.Events.FetchLatest<KeyedLetters>(streamKey!) should be null but was EventSourcingTests.KeyedLetters
failed ...the_batched_fetch_latest_of_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: "")
failed ...the_batched_fetch_latest_of_an_empty_or_null_key_does_not_fold_the_whole_store(streamKey: null)
  await latest should be null but was EventSourcingTests.KeyedLetters
failed ...compacting_the_empty_guid_leaves_every_other_stream_alone
  first.Select(x => x.Data.GetType()) should be [AEvent, BEvent, AEvent] but was []
failed ...compacting_the_empty_guid_is_refused
  Task `() => session.Events.CompactStreamAsync<Letters>(Guid.Empty)` should throw System.ArgumentOutOfRangeException but did not
failed ...compacting_an_empty_guid_stream_that_does_exist_is_refused_too
  ... should throw System.ArgumentOutOfRangeException but did not
failed ...compacting_an_empty_or_null_key_is_refused_and_leaves_every_stream_alone(streamKey: "")
failed ...compacting_an_empty_or_null_key_is_refused_and_leaves_every_stream_alone(streamKey: null)
  (await query.Events.FetchStreamAsync("first")).Count should be 3 but was 0
  total: 22, failed: 20, succeeded: 2

Green with the fix

EventSourcingTests.empty_stream_identity_is_not_a_wildcard     total: 22  failed: 0  succeeded: 22

Neighbouring tests that go through EventStatement, all on the fix, net10.0, against postgres:17-alpine:

EventSourcingTests.FetchForWriting (namespace)   total: 283   failed: 0  succeeded: 283
EventSourcingTests.Aggregation (namespace)       total: 159   failed: 0  succeeded: 158   skipped: 1
EventSourcingTests Compliance.*, end_to_end_event_capture_and_fetching_the_stream (+ _with_string_identifiers),
  event_store_with_string_identifiers_for_stream, fetching_stream_query_plans, fetching_stream_state,
  native_batch_reads, multi_tenancy_and_event_capture, marking_events_as_skipped_* (Guid and string),
  archiving_events, partitioned_stream_archiving, query_stream_states_refusals,
  reading_the_event_store_with_the_default_tenant_disabled, determining_the_event_store_identity,
  appending_events_workflow_specs, ScenarioAggregateAndRepository, aggregateto_linq_operator_tests,
  delete_single_event_stream, replacing_events, protecting_sensitive_data, removing_protected_information
                                                 total: 1167  failed: 0  succeeded: 1147  skipped: 20
TenantPartitionedEventsTests ReadQuery, Regressions
                                                 total: 55    failed: 0  succeeded: 53    skipped: 2

🤖 Generated with Claude Code

…y, and refuse them in CompactStreamAsync

EventStatement emitted its stream predicate only if (StreamId != Guid.Empty), and only for a
non-empty StreamKey. Every caller sets exactly one of the two, so "empty means no filter" was never
a contract -- it fell out of the property defaults. For Guid.Empty, "" and a null key alike:

- FetchStreamAsync and the batched FetchStream returned every non-archived event of the tenant (of
  the store, when single-tenanted);
- AggregateStreamAsync<T>, and the live plan's FetchLatest<T> from a session or a batch, folded them
  all into one aggregate;
- CompactStreamAsync<T> rewrote the newest event in the store -- another stream's -- as
  Compacted<T> and deleted every other event, in one transaction.

A set value is now a stream. EventStatement.StreamId is Guid?, and a string key is tracked as
assigned by its setter rather than read as non-empty. Guid.Empty and "" read that stream: what
BulkInsertEventsAsync (or legacy data, or SQL) wrote under it, and nothing otherwise, as a read of
any unknown id does. A null key names no stream -- stream_id is never null -- so it reads nothing.
Both CompactStreamAsync overloads refuse Guid.Empty and a null or empty key with the
ArgumentOutOfRangeException Append and StartStream already throw, so the one API here that deletes
events never depends on a read predicate for its reach. FetchForWriting needed no change on either
path: it already ends in the start-stream guard for all three, and the tests pin that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 9f3fc0e into JasperFx:master Oct 19, 2026
32 checks passed
This was referenced Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants