Skip to content

Fix #5062: mt_quick_append_events returned {NULL} for an empty event array - #5088

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5062-quick-append-empty-event-array
Jul 30, 2026
Merged

Fix #5062: mt_quick_append_events returned {NULL} for an empty event array#5088
jeremydmiller merged 1 commit into
masterfrom
fix/5062-quick-append-empty-event-array

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #5062.

The defect

array_length('{}', 1) is NULL in PostgreSQL, not 0. So

return_value := ARRAY[event_version + array_length(event_ids, 1)];

evaluated to NULL whenever mt_quick_append_events was called with no events, and the function returned a single-element array holding NULL. Npgsql then failed the read into long[]:

System.InvalidCastException: Cannot read a non-nullable collection of elements because the
returned array contains nulls. Call GetFieldValue with a nullable collection type instead.

Why it masked the real error

The cast failure surfaced from QuickAppendEventsOperationBase.PostprocessAsync — i.e. from inside OperationPage.ApplyCallbacksAsync, the loop that collects per-operation exceptions into the batch's exceptions list. A throw out of that loop lands in ExecuteBatchPagesAsync's catch, which rethrows the thrown exception and discards everything already collected. Callers therefore got an unrelated, non-retryable InvalidCastException instead of whatever actually made the append fail. On the reporter's system that dead-lettered Wolverine messages that would otherwise have been retried.

The fix

Three coordinated changes:

  1. QuickAppendEventFunctionCOALESCE(array_length(event_ids, 1), 0), so the empty case means what it says: zero events appended, final version = the stream's current version. This is the reporter's suggested fix and the actual correctness fix.

  2. QuickAppendEventsOperationBase.PostprocessAsync — skip the field-0 read when the stream carries no events. Nothing needs post-processing there anyway, and this matters for databases whose function has not been migrated yet (AutoCreate.None + explicit migrations): they stop throwing the cast exception on the old function. The row is still consumed, so the reader stays aligned for the rest of the page.

  3. ProjectionUpdateBatch.WaitForCompletion — the one caller in Marten that could reach the function with empty arrays (an Append side effect that ended up with no events; every other path already filters on Events.Any()). A StartStream still gets its mt_streams row.

Tests

TenantPartitionedEventsTests/Regressions/Bug_5062_empty_quick_append.cs, on the reported configuration (Conjoined + UseTenantPartitionedEvents + Quick):

  • function_returns_a_non_null_version_for_an_empty_event_array — the issue's SQL repro. The function's parameter list varies with configuration (metadata columns, server timestamps, tag tables, bigint events), so the test reads the deployed signature back out of pg_get_function_arguments and feeds every array parameter an empty array. That pins the function itself rather than one config's hand-written call.
  • empty_quick_append_operation_is_a_clean_no_op — the same empty append driven through Marten's own generated call site and result read, which is where the InvalidCastException actually landed.

Both fail on master with the exact reported InvalidCastException; both pass with the fix. Full TenantPartitionedEventsTests suite green on net9.0 (238 passed, 2 pre-existing skips).

Upgrade note

The function body changed, so existing databases will show one mt_quick_append_events update in the next schema migration. It is idempotent afterwards.

🤖 Generated with Claude Code

…array

`array_length('{}', 1)` is NULL in PostgreSQL rather than 0, so
`event_version + array_length(event_ids, 1)` evaluated to NULL and the
function returned a single-element array holding NULL whenever it was
called with no events. Npgsql then failed the read into `long[]`:

    System.InvalidCastException: Cannot read a non-nullable collection of
    elements because the returned array contains nulls.

That cast failure surfaced from QuickAppendEventsOperationBase.Postprocess
Async -- inside the batch's callback loop -- so it propagated out of the
loop and discarded whatever exception had already been collected, leaving
callers with an unrelated, non-retryable InvalidCastException instead of
the real error. On the reporter's system that dead-lettered Wolverine
messages that would otherwise have been retried.

Three coordinated changes:

* COALESCE the array_length so the empty case means what it says: zero
  events appended, final version = the stream's current version.
* Skip the field-0 read in PostprocessAsync when the stream carries no
  events. Nothing needs post-processing there anyway, and this keeps a
  database whose function has not been migrated yet from throwing the
  cast exception (the row is still consumed, so the reader stays aligned
  for the rest of the page).
* Don't issue the call at all for a zero-event Append in
  ProjectionUpdateBatch.WaitForCompletion -- the one caller in Marten
  that could reach the function with empty arrays. A StartStream still
  gets its mt_streams row.

Regression tests pin both the function (built from the deployed signature
read out of pg_get_function_arguments, so it holds across metadata /
timestamp / tag / bigint configurations) and Marten's own generated call
site. Both fail on master with the reported InvalidCastException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 70c4005 into master Jul 30, 2026
10 checks passed
@jeremydmiller
jeremydmiller deleted the fix/5062-quick-append-empty-event-array branch July 30, 2026 00:26
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.

mt_quick_append_events returns {NULL} for an empty event list, masking the original exception

1 participant