Skip to content

fix: keep Firestore session events in time order - #1653

Open
innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/firestore-event-order
Open

innoprej wants to merge 1 commit into
google:mainfrom
innoprej:fix/firestore-event-order

Conversation

@innoprej

@innoprej innoprej commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

Problem:

FirestoreSessionService stores each event's timestamp as Instant.toString(), which leaves out the fraction when it is zero, and orders and filters events by comparing those strings. Within one second, an event at .000 (…05Z) sorts after the later events (…05.400Z), so getSession and listEvents return them out of order. afterTimestamp is compared as text too and with >: a whole-second cursor leaves out the rest of that second, a millisecond cursor includes an earlier whole-second event, and an event at exactly the cursor is left out, while InMemorySessionService, VertexAiSessionService and ADK Python keep the events at or after the cursor. #1646 has the details and a reproduction.

Solution:

  • eventToMap writes timestamp with exactly three fraction digits (DateTimeFormatterBuilder.appendInstant(3), for example 2026-10-10T05:00:05.000Z). Event timestamps are whole milliseconds, so the digits are exact and text order is time order. eventFromMap and FirestoreMemoryService read both forms, as before.
  • getSession rounds afterTimestamp up to whole milliseconds, writes it the same way and filters with whereGreaterThanOrEqualTo. An event at millisecond m is at or after the cursor t exactly when m >= ceil(t), which is what InMemorySessionService keeps. The query still orders and filters on the one field, so no new index is needed.
  • If afterTimestamp is later than the largest timestamp an event can have
    (Long.MAX_VALUE epoch milliseconds), getSession returns the session
    with no events without issuing the event query. This also avoids overflow
    when rounding a cursor near Instant.MAX.
  • getSession and listEvents sort the events they read by timestamp, as VertexAiSessionService.filterEvents does. Events stored before this change keep their old strings, and this keeps those sessions in time order too.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

On Windows 11 with Microsoft Build of OpenJDK 17.0.19,
./mvnw -pl contrib/firestore-session-service test ran 60 tests with two
failures, both in FirestorePropertiesTest:
shouldReturnHardcodedDefaultIfPropertyFileHasNoEntry and
shouldLoadDevPropertiesWhenEnvIsSetToDev. The same two tests failed with
the same messages in the 55-test baseline run on the previous PR head
153a150a. The focused FirestoreSessionServiceTest run passed all 37 tests.

On the updated head 1a6dd29e, GitHub Actions Scan and CLA checks passed.
validation, PR Title Check, and PR Commit Check are awaiting approval
to run (action_required).

Test On main With this change
appendEvent_storesTimestampsWithThreeFractionDigits: events at 05.000, 05.400 and 06.000 fails: stored as …05Z, …05.400Z, …06Z passes
Cursor tests for whole-second, millisecond, and sub-millisecond inputs Not separately rerun on upstream main pass; verify the query cursor and successful session read with complete session data
getSession_withAfterTimestampAtInstantMax_returnsSessionWithoutEvents Not run on upstream main; fails with DateTimeException on previous PR head 153a150a passes; returns the session with no events without issuing the event query
getSession_eventsStoredOutOfOrder_returnsEventsInTimeOrder: documents …05.400Z and …05Z, in Firestore's text order fails: 05.400 first passes
listEvents_eventsStoredOutOfOrder_returnsEventsInTimeOrder: the same for listEvents fails: 05.400 first passes

In the earlier test of PR head 153a150a, rounding the cursor down instead
of up caused the sub-millisecond case to fail (…05.200Z instead of
…05.201Z).

Manual End-to-End (E2E) Tests:

Not run against a real Firestore database or the emulator. The tests check the strings and the query the service sends to a mocked Firestore; the order and the result sets then follow from Firestore's documented ordering of strings by UTF-8 bytes. In an earlier local run on PR head 153a150a with #1643 and #1650 applied
together, all 38 tests in FirestoreSessionServiceTest passed. That combined
tree was not rerun with the current review changes.

Checklist

  • I have read the CONTRIBUTING.md document.
  • My pull request contains a single commit.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

  • Not covered: events with the same millisecond (point 2 in FirestoreSessionService compares event timestamps as text, reordering or skipping same-second events #1646) still have no tie-breaker, since that needs an extra field or time-ordered document IDs, and ordering on a second field needs a composite index. The sort above is stable, so tied events keep the order Firestore returns. I can open a separate issue for it, so that this PR can close FirestoreSessionService compares event timestamps as text, reordering or skipping same-second events #1646.
  • Events stored before this change keep their old strings, and the sort only fixes the order they are returned in. Firestore still selects them by text, so within one second numRecentEvents can return an old …05Z event instead of a later …05.400Z one, and afterTimestamp can include an old …05Z event although the cursor is later in that second. It no longer leaves out events at or after the cursor.
  • timestamp stays a string, a deliberate difference from ADK Python noted in the TIMESTAMP_FORMATTER Javadoc. ADK Python stores a Firestore timestamp, but Firestore sorts timestamps before strings, so sessions holding both would be misordered unless the stored events are migrated. If you would prefer the timestamp type with a migration, I can change this.
  • FirestoreMemoryService returns the stored string as MemoryEntry.timestamp, so entries for events at a whole second now read …05.000Z instead of …05Z. Both are ISO 8601.
  • The session's updateTime is still written with toString(); it is only parsed back, never compared in a query.

@hemasekhar-p

Copy link
Copy Markdown
Contributor

Hi @innoprej, We greatly appreciate your contribution and the effort you put into this pull request. It is currently under review by our team. We will notify you if any additional details are needed.

if (config.afterTimestamp().isPresent()) {
// Round up to whole milliseconds, the precision of stored event timestamps.
Instant cursor =
config.afterTimestamp().get().plusNanos(999_999).truncatedTo(ChronoUnit.MILLIS);

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.

plusNanos(999_999) overflows near Instant.MAX: an afterTimestamp of Instant.MAX throws DateTimeException instead of returning no events like InMemorySessionService. This is an edge case, but the overflow is easy to guard against.

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.

Thanks for catching this. I added a guard for cursors later than the maximum event timestamp representable as a long epoch-millisecond value. In that case, getSession returns the session with no events without issuing the event query. The new tests cover both Instant.MAX and a cursor one nanosecond beyond the maximum event timestamp.

.thenReturn(mockQuery);
when(mockQuery.get()).thenReturn(ApiFutures.immediateFuture(mockQuerySnapshot));

storedCursorByAfterTimestamp.forEach(

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.

Running three cases in one forEach lets the first failure hide the other two and forces one test name to cover three behaviors. Splitting this into three small tests (whole second, millisecond, sub-millisecond rounded up) would isolate each case. Also, each getSession(...).test() here fails silently because the stubbed session data lacks updateTime, so Instant.parse throws. Stubbing the full session map and calling assertComplete() would test the read path too, not just the query.

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.

Thanks for pointing out the silent failure. I split the whole-second, millisecond, and sub-millisecond cases into separate tests, supplied the complete session data including updateTime, and added assertions that the read completes without errors and returns the expected session.

private static final String UPDATE_TIME_KEY = Constants.KEY_UPDATE_TIME;
private static final String TIMESTAMP_KEY = Constants.KEY_TIMESTAMP;

/** Text, not a timestamp as in ADK Python, with three fraction digits so it sorts by time. */

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.

LOW: The summary does not state what this constant is or why the field stays text. Something like "Formats stored event timestamps with exactly three fraction digits, so text order is time order" works, followed by the reason from your commit message: Firestore orders timestamps before strings, so switching types would misorder existing sessions.

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.

Thanks, I updated the Javadoc to describe the fixed three-digit fractional format and explain why the field remains text for compatibility with existing events.

@damianmomotgoogle damianmomotgoogle added waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale. and removed needs review labels Oct 28, 2026
FirestoreSessionService stored event timestamps with
Instant.toString(), which leaves out the fraction when it is zero, and
ordered and filtered events by comparing those strings. Within one
second, an event at .000 sorted after the later events, a whole-second
afterTimestamp cursor left out the rest of that second, and
afterTimestamp used > where InMemorySessionService,
VertexAiSessionService and ADK Python keep events at or after the
cursor.

- Event timestamps are written with exactly three fraction digits.
- afterTimestamp is rounded up to whole milliseconds, written the same
  way and matched with >=.
- An afterTimestamp later than any event can have (event timestamps are
  epoch milliseconds in a long) returns no events, as
  InMemorySessionService does, instead of overflowing when rounded up
  near Instant.MAX.
- getSession and listEvents sort the events they read by timestamp, so
  events stored in the old format also come back in time order.

The field stays a string. ADK Python stores a Firestore timestamp, but
Firestore orders timestamps before strings, so sessions with events
stored before this change would be misordered.
@innoprej
innoprej force-pushed the fix/firestore-event-order branch from 153a150 to 1a6dd29 Compare October 29, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting on reporter Waiting for reaction by reporter. Failing that, maintainers will eventually closed it as stale.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FirestoreSessionService compares event timestamps as text, reordering or skipping same-second events

3 participants