Skip to content

Emit all messages across sequence number gaps in messagesWithBatch#517

Merged
pjfanning merged 4 commits into
apache:mainfrom
mucciolo:emit-all-messages-across-sequence-gaps-516
Jun 13, 2026
Merged

Emit all messages across sequence number gaps in messagesWithBatch#517
pjfanning merged 4 commits into
apache:mainfrom
mucciolo:emit-all-messages-across-sequence-gaps-516

Conversation

@mucciolo

@mucciolo mucciolo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #516

The bug

messagesWithBatch backs eventsByPersistenceId, currentEventsByPersistenceId and actor recovery. Since the query windowing introduced in #180, a batch window [from, from + batchSize] containing fewer live messages than batchSize is treated as the end of the journal:

  • bounded streams complete early and silently drop every message beyond the gap;
  • live streams with a refresh interval poll the same empty window forever and never emit again.

Gaps in sequence numbers are produced by deleteMessages (hard deletes below the retained marker row) and by cleanup of soft-deleted rows. #195 fixed this only for a gap at the head of the journal (the snapshot cleanup case). Upstream akka-persistence-jdbc does not have this bug. A full analysis and regression timeline is in #516.

The fix

The unfoldAsync state in BaseJournalDaoWithReadMessages becomes a small query-plan state machine:

  • QueryRemaining(from) queries the whole remaining range [from, toSequenceNr] with the batch-size LIMIT. It is planned first, and whenever a short windowed batch leaves the remainder undetermined, since gaps cannot hide messages from an unwindowed query.
  • QueryWindow(from, endInclusive) is the dense fast path, planned only after a full batch showed the journal to be dense. This keeps the perf safeguard of perf: avoid large offset query via limit windowing #180; it is not load-bearing for correctness because a short window falls back to QueryRemaining.
  • PollRemaining(from, delay, scheduler) is the live tail. It polls the full remaining range: a windowed poll can never reach messages appended beyond a trailing gap.
  • Complete.
flowchart TD
    start((start)) --> QueryRemaining

    QueryRemaining -->|"full batch"| QueryWindow
    QueryRemaining -->|"short batch, polling"| PollRemaining
    QueryRemaining -->|"reached the end, or short batch when not polling"| Complete

    QueryWindow -->|"full batch"| QueryWindow
    QueryWindow -->|"short batch before toSequenceNr (gap fallback)"| QueryRemaining
    QueryWindow -->|"short batch at toSequenceNr, polling"| PollRemaining
    QueryWindow -->|"reached the end, or short batch at toSequenceNr when not polling"| Complete

    PollRemaining -->|"full batch"| QueryWindow
    PollRemaining -->|"short batch"| PollRemaining
    PollRemaining -->|"reached the end"| Complete

    Complete --> done((done))

    linkStyle 5 stroke:red,stroke-width:2px,color:red
Loading
  • full batch: the query returned batchSize messages; short batch: fewer.
  • reached the end: the last message is at or beyond toSequenceNr (or, on the first query, the requested range was already empty).
  • polling: a refresh interval is configured.

The bug was the missing QueryWindow to QueryRemaining edge, drawn in red above: a short windowed batch was treated as the end of the journal.

Notes:

  • The first query spans the full remaining range, so a journal head purged up to a snapshot is crossed in one round trip, subsuming the limit windowing support snapshoted events #195 special case. The limited query costs the same as a windowed one when the journal is dense.
  • Cost of a gap on the dense path: one extra full-range limited query per short windowed batch. A dense journal issues the same queries as before, pinned by an interaction test.
  • Public signatures are unchanged.

Tests

  • MessagesWithBatchTest (core): the gap state machine specified against an in-memory journal stub honoring the messages contract (ascending order, inclusive bounds, LIMIT). 26 tests covering gaps narrower and wider than the batch size, leading and trailing gaps, live polling across gaps, bounded live completion, failure pass-through and the two interaction tests pinning the first-query full range and the dense windowing of perf: avoid large offset query via limit windowing #180.
  • MessagesWithBatchDatabaseContractTest (H2 in core; Postgres, MySQL, MariaDB, Oracle and SQL Server in integration-test): hard-deleted gaps, soft-deleted messages, a mixed hard/soft gap, a prefix purge through the journal's delete, and a corrupt-row serialization failure.

…ache#516

* The query windowing introduced by apache#180 treated a windowed batch returning
  fewer messages than batchSize as the end of the journal: bounded streams
  (recovery, currentEventsByPersistenceId) completed early and silently
  dropped every message beyond a gap left by deleted messages, and live
  streams stalled forever polling an empty window. apache#195 fixed only the gap
  at the head of the journal.
* Rework the unfoldAsync state from FlowControl into a QueryPlan state
  machine (QueryRemaining, QueryWindow, PollRemaining, Complete): a short
  windowed batch is no longer conclusive and falls back to one query over
  the full remaining range, which gaps cannot hide messages from.
* Keep windowing as the dense fast path of apache#180: after a full batch shows
  the journal to be dense, queries stay bounded to [from, from + batchSize].
* Span the full remaining range on the first query, crossing a deleted
  journal head (snapshot cleanup) in a single round trip, subsuming the
  apache#195 special case.
* Poll the full remaining range when tailing: a windowed poll can never
  reach messages appended beyond a trailing gap.
* Specify the gap state machine in MessagesWithBatchTest (core, in-memory
  journal stub) and the database-coupled behavior in
  MessagesWithBatchDatabaseContractTest (H2 plus the five integration
  databases): hard and soft deletes, a prefix purge through the journal's
  delete, and serialization failures.
@mucciolo mucciolo changed the title Emit all messages across sequence number gaps in messagesWithBatch #516 Emit all messages across sequence number gaps in messagesWithBatch Jun 7, 2026
@pjfanning

Copy link
Copy Markdown
Member

@mucciolo thanks very much for the PR. Would you have any interest in adding the equivalent tests to https://github.com/apache/pekko-persistence-r2dbc or alternatively to the persistence-tck that is part of https://github.com/apache/pekko? It would be great to ensure consistent behaviour across different persistence implementations. Feel free to say no.

@pjfanning

pjfanning commented Jun 7, 2026

Copy link
Copy Markdown
Member

the oracle integration tests fail due to integrity constraints for nulls - we might need to change the DDL for oracle

but on a quick check for
java.sql.SQLIntegrityConstraintViolationException, with message: ORA-01400: cannot insert NULL into ("SYSTEM"."EVENT_JOURNAL"."WRITER")

this writer column is marked as NOT NULL on our database dialects too

It seems feasible that we have a code issue that affects oracle usage

mucciolo added 2 commits June 7, 2026 18:42
Oracle treats the empty string as NULL, so inserting the synthetic
corrupt row with writer = "" violated the NOT NULL constraint on
EVENT_JOURNAL.WRITER (ORA-01400) and failed only the Oracle integration
tests. The production write path always carries a writer UUID, so this is
a test-fixture-only fix.
These files contain no Akka-derived code, so they should carry the full
ASF header (the headerLicense the build stamps on new files) rather than
the shorter "derived from Akka" variant, per a maintainer review note.
@mucciolo

mucciolo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

the oracle integration tests fail due to integrity constraints for nulls - we might need to change the DDL for oracle

but on a quick check for java.sql.SQLIntegrityConstraintViolationException, with message: ORA-01400: cannot insert NULL into ("SYSTEM"."EVENT_JOURNAL"."WRITER")

this writer column is marked as NOT NULL on our database dialects too

It seems feasible that we have a code issue that affects oracle usage

f8c957b

@mucciolo

mucciolo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

@mucciolo thanks very much for the PR. Would you have any interest in adding the equivalent tests to https://github.com/apache/pekko-persistence-r2dbc or alternatively to the persistence-tck that is part of https://github.com/apache/pekko? It would be great to ensure consistent behaviour across different persistence implementations. Feel free to say no.

If that's only about porting tests, I'm open to looking at Persistence TCK. No promises, but it's not a no either. Mind sharing more about what you're thinking?

@mucciolo mucciolo requested a review from pjfanning June 7, 2026 22:30
@pjfanning

Copy link
Copy Markdown
Member

@mucciolo thanks very much for the PR. Would you have any interest in adding the equivalent tests to https://github.com/apache/pekko-persistence-r2dbc or alternatively to the persistence-tck that is part of https://github.com/apache/pekko? It would be great to ensure consistent behaviour across different persistence implementations. Feel free to say no.

If that's only about porting tests, I'm open to looking at Persistence TCK. No promises, but it's not a no either. Mind sharing more about what you're thinking?

It's just about sharing the test scenarios.

@mucciolo

mucciolo commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

@mucciolo thanks very much for the PR. Would you have any interest in adding the equivalent tests to https://github.com/apache/pekko-persistence-r2dbc or alternatively to the persistence-tck that is part of https://github.com/apache/pekko? It would be great to ensure consistent behaviour across different persistence implementations. Feel free to say no.

If that's only about porting tests, I'm open to looking at Persistence TCK. No promises, but it's not a no either. Mind sharing more about what you're thinking?

It's just about sharing the test scenarios.

Issue required?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a regression in BaseJournalDaoWithReadMessages.messagesWithBatch where sequence-number gaps (from hard deletes and soft-delete cleanup) could cause batched streams to prematurely stop (bounded queries) or poll the same empty window forever (live queries). It does so by replacing the previous windowing-only unfoldAsync logic with a small query-plan state machine that can safely fall back to full-range queries when a windowed batch is short, ensuring messages beyond gaps are still emitted.

Changes:

  • Reworked internalBatchStream into a query-plan state machine (QueryRemaining, QueryWindow, PollRemaining, Complete) to correctly handle gaps while preserving the dense fast-path windowing optimization.
  • Added a comprehensive in-memory unit test suite (MessagesWithBatchTest) specifying correct behavior across a variety of gap/polling scenarios.
  • Added database contract tests (MessagesWithBatchDatabaseContractTest) and integration-test wrappers to validate behavior across supported DBs (including hard/soft deletes, journal delete, and deserialization failures).

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
core/src/main/scala/org/apache/pekko/persistence/jdbc/journal/dao/BaseJournalDaoWithReadMessages.scala Implements the new query-plan state machine to ensure gaps don’t truncate or stall batched reads.
core/src/test/scala/org/apache/pekko/persistence/jdbc/journal/dao/MessagesWithBatchTest.scala In-memory state-machine specification covering gap shapes, polling, completion behavior, and failure propagation.
core/src/test/scala/org/apache/pekko/persistence/jdbc/journal/dao/MessagesWithBatchDatabaseContractTest.scala DB-backed contract tests for delete semantics and corrupt-row failure behavior (H2 in core, reused by integration).
integration-test/src/test/scala/org/apache/pekko/persistence/jdbc/integration/MessagesWithBatchDatabaseContractTest.scala Integration-test DB matrix wrappers to run the contract suite on Postgres/MySQL/MariaDB/Oracle/SQL Server.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@pjfanning

pjfanning commented Jun 7, 2026

Copy link
Copy Markdown
Member

@mucciolo thanks very much for the PR. Would you have any interest in adding the equivalent tests to https://github.com/apache/pekko-persistence-r2dbc or alternatively to the persistence-tck that is part of https://github.com/apache/pekko? It would be great to ensure consistent behaviour across different persistence implementations. Feel free to say no.

If that's only about porting tests, I'm open to looking at Persistence TCK. No promises, but it's not a no either. Mind sharing more about what you're thinking?

It's just about sharing the test scenarios.

Issue required?

@mucciolo if you are working on a PR then there is no need for a separate issue. Issues are best used when there is a need to track something that is not yet ready to work on.

@He-Pin He-Pin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Emit all messages across sequence number gaps

Overall Assessment

This is a well-crafted fix for a real correctness bug. The QueryPlan state machine is a clean, well-documented design that correctly handles sequence number gaps while preserving the performance safeguard of windowed queries from PR #180.

Findings

🟡 suggestion: LimitWindowingStreamTest query range expectations may be stale

LimitWindowingStreamTest.scala:77 calls internalBatchStream directly. The old code used limitWindow(from) which produced windows of batchSize sequence numbers (e.g., [101, 200] for batchSize=100). The new code uses windowedQuery(from) which produces windows of batchSize + 1 sequence numbers (e.g., [101, 201]). This is intentional — the extra slot lets a window tolerate one missing sequence number and still return a full batch.

The test only checks batchCount and totalCount, not queriedRanges, so for consecutive messages it still passes (both produce 1600 batches of 100). However, if the test is ever extended to check ranges, it will fail. Consider updating the test to also record and verify the new expected ranges, or adding a comment explaining the difference.

✅ Strengths

  1. Correct gap handling: The QueryRemainingQueryWindowQueryRemaining fallback on short windowed batches is the right design. A short windowed batch is inconclusive (gaps may hide messages), while a short full-range batch is conclusive (reached the end).

  2. First query full range: Starting with QueryRemaining instead of a windowed query correctly handles leading gaps (e.g., journal purged up to a snapshot) in a single round trip, subsuming the #195 special case.

  3. Overflow handling: The windowedQuery overflow guard from <= (Long.MaxValue - normalizedBatchSize) is correct and matches the old limitWindow logic.

  4. Poll semantics preserved: PollRemaining polls the full remaining range, which is correct — a windowed poll can never reach messages appended beyond a trailing gap.

  5. Comprehensive test coverage: 26 unit tests covering narrow/wide gaps, leading/trailing gaps, live polling, bounded live completion, failure pass-through, and the two interaction tests pinning first-query full range and dense windowing.

  6. normalizedBatchSize = Math.max(1, batchSize): Correctly handles batchSize=0 and negative values.

  7. isEmptyRange = from > toSequenceNr: Correctly handles fromSequenceNr beyond toSequenceNr without polling forever.

  8. FlowControl not deleted: The PR correctly removes only the import from this file; FlowControl is still used by JdbcDurableStateStore and JdbcReadJournal.

No blockers found. The implementation is correct and the test coverage is thorough.


/**
* separate this method for unit tests.
* Separate this method for unit tests.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 suggestion: The window size changed from batchSize to batchSize + 1 sequence numbers. This is correct — the extra slot lets a window tolerate one gap and still return a full batch. However, LimitWindowingStreamTest.scala:77 calls internalBatchStream directly. The old code produced windows of batchSize numbers (e.g., [101, 200]); the new code produces batchSize + 1 (e.g., [101, 201]). The test only checks batch/message counts (not ranges), so it still passes for consecutive messages. But if it's ever extended to check queriedRanges, it will fail. Consider updating it or adding a comment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept this count-only with a note on the batchSize+1 width. The exact ranges are already pinned in-memory by MessagesWithBatchTest, so asserting them here too would duplicate that and couple the test to the window arithmetic. 5d8c8e1

val normalizedFromSequenceNr = Math.max(1, fromSequenceNr)

def pollOrComplete(from: Long): QueryPlan = refreshInterval match {
case Some((delay, scheduler)) => PollRemaining(from, delay, scheduler)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ good: Handling from > toSequenceNr as isEmptyRange correctly prevents a live stream from polling a range that can never produce messages. This edge case is important for the fromSequenceNr > toSequenceNr scenario (tested in MessagesWithBatchTest).

QueryWindow(from, endInclusive)
}

def retrieveBatch(from: Long, endInclusive: Long): Future[Option[(QueryPlan, Seq[Try[(PersistentRepr, Long)]])]] =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ nice design: The unfoldAsync pattern matching on QueryPlan subtypes is much cleaner than the old FlowControl approach. The state machine is self-documenting — each case in the match clearly shows what query runs and what the next state is. The sealed trait hierarchy with QueryWindow/QueryRemaining/PollRemaining/Complete makes the transition graph explicit.

private[dao] object BaseJournalDaoWithReadMessages {

/** The query the batch stream will run. */
private sealed trait QueryPlan

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QueryAction?

@mucciolo mucciolo Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Query" is also a verb, which implies action, so no semantic gain over QueryPlan. Happy to consider alternatives if you feel strongly.

@pjfanning

Copy link
Copy Markdown
Member

@mucciolo I added apache/pekko-persistence-r2dbc#398 to bring similar test coverage to pekko-persistence-r2dbc.

@pjfanning pjfanning left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

thanks @mucciolo - I'll leave it another day or 2 before merging just in case any of the reviewers raise issues

@pjfanning pjfanning merged commit 40cdd6d into apache:main Jun 13, 2026
20 of 21 checks passed
pjfanning added a commit that referenced this pull request Jun 14, 2026
) (#521)

* Emit all messages across sequence number gaps in messagesWithBatch #516

* The query windowing introduced by #180 treated a windowed batch returning
  fewer messages than batchSize as the end of the journal: bounded streams
  (recovery, currentEventsByPersistenceId) completed early and silently
  dropped every message beyond a gap left by deleted messages, and live
  streams stalled forever polling an empty window. #195 fixed only the gap
  at the head of the journal.
* Rework the unfoldAsync state from FlowControl into a QueryPlan state
  machine (QueryRemaining, QueryWindow, PollRemaining, Complete): a short
  windowed batch is no longer conclusive and falls back to one query over
  the full remaining range, which gaps cannot hide messages from.
* Keep windowing as the dense fast path of #180: after a full batch shows
  the journal to be dense, queries stay bounded to [from, from + batchSize].
* Span the full remaining range on the first query, crossing a deleted
  journal head (snapshot cleanup) in a single round trip, subsuming the
  #195 special case.
* Poll the full remaining range when tailing: a windowed poll can never
  reach messages appended beyond a trailing gap.
* Specify the gap state machine in MessagesWithBatchTest (core, in-memory
  journal stub) and the database-coupled behavior in
  MessagesWithBatchDatabaseContractTest (H2 plus the five integration
  databases): hard and soft deletes, a prefix purge through the journal's
  delete, and serialization failures.

* Use a non-empty writer in the corrupt-row test fixture

Oracle treats the empty string as NULL, so inserting the synthetic
corrupt row with writer = "" violated the NOT NULL constraint on
EVENT_JOURNAL.WRITER (ORA-01400) and failed only the Oracle integration
tests. The production write path always carries a writer UUID, so this is
a test-fixture-only fix.

* Use the standard Apache header on the new test files

These files contain no Akka-derived code, so they should carry the full
ASF header (the headerLicense the build stamps on new files) rather than
the shorter "derived from Akka" variant, per a maintainer review note.

* Note batchSize+1 window width in LimitWindowingStreamTest

Co-authored-by: Diego D. Mucciolo <2973680+mucciolo@users.noreply.github.com>
@mucciolo

Copy link
Copy Markdown
Contributor Author

@mucciolo I added apache/pekko-persistence-r2dbc#398 to bring similar test coverage to pekko-persistence-r2dbc.

And I to Persistence TCK apache/pekko#3076

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.

Batched message stream stops at sequence number gaps, losing messages during recovery (regression in 1.1.0 via #180)

4 participants