Skip to content

Fix timezone-aware datetime roundtrip on SQLite - #1462

Open
edwinyyyu wants to merge 2 commits into
MemMachine:mainfrom
edwinyyyu:sqlalchemy_timezone_roundtrip
Open

Fix timezone-aware datetime roundtrip on SQLite#1462
edwinyyyu wants to merge 2 commits into
MemMachine:mainfrom
edwinyyyu:sqlalchemy_timezone_roundtrip

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Problem

SQLAlchemySegmentStore (and a couple of other SQLAlchemy-backed stores) can corrupt timezone-aware timestamps when running on SQLite.

SQLite's DateTime(timezone=True) does not store timezone information. SQLAlchemy's SQLite dialect discards tzinfo and persists the datetime's wall-clock fields verbatim, reading them back naive. So when a store wrote a timezone-aware datetime without first normalizing it to UTC, the stored wall-clock was the local time of the original zone. On read the value was treated as UTC and then shifted to the recorded offset — corrupting the instant by the original UTC offset.

Concretely, a segment timestamp of 2024-01-01 13:30:45-08:00 came back as 2024-01-01 05:30:45-08:00.

PostgreSQL is unaffectedtimestamptz stores a true instant — so this change is a no-op there.

Proof

original                     : 2026-06-19 13:26:43-08:00 -> instant 2026-06-19 21:26:43+00:00
read back (current write)    : datetime.datetime(2026, 6, 19, 13, 26, 43) tzinfo=None
reconstructed (current)      : 2026-06-19 05:26:43-08:00   CORRECT? False
reconstructed (UTC-norm)     : 2026-06-19 13:26:43-08:00   CORRECT? True

Fix

Normalize to UTC (ensure_tz_aware(...).astimezone(UTC)) before persisting wherever an app-supplied timezone-aware datetime is written to a DateTime(timezone=True) column:

  • SQLAlchemySegmentStore.timestamp — the primary report. The original offset is already stored separately (timestamp_timezone_offset) and reapplied on read; only the write was missing UTC normalization.
  • ClusterStateStorageSqlAlchemylast_ts and pending created_at.
  • SqlAlchemyEpisodeStorecreated_at, plus the start_time/end_time filter bounds so range comparisons stay consistent with the stored UTC instant on SQLite.

Server-generated columns (server_default=func.now()) are always UTC and roundtrip fine, so they are left unchanged. No schema changes; no migration (existing SQLite rows are not rewritten — the read path already assumes UTC, which new writes now honor).

Tests

Added SQLite regression tests covering non-UTC timezones (-08:00 and +05:30) for the segment store, cluster store, and episode store. Each was confirmed to fail before the fix and pass after. Existing tests for the affected modules continue to pass (952 passed locally, non-integration).

🤖 Generated with Claude Code

SQLite's DateTime(timezone=True) does not store timezone information: it
discards tzinfo and persists the datetime's wall-clock fields verbatim,
reading them back naive. Stores that wrote a timezone-aware datetime
without first normalizing to UTC therefore corrupted the value on read by
the original UTC offset. PostgreSQL is unaffected because timestamptz
stores a true instant, so this fix is a no-op there.

Normalize to UTC (ensure_tz_aware(...).astimezone(UTC)) before persisting
in every affected store:

- SQLAlchemySegmentStore.timestamp (the original offset is already stored
  separately and reapplied on read; only the write was missing the UTC
  normalization)
- ClusterStateStorageSqlAlchemy last_ts and pending created_at
- SqlAlchemyEpisodeStore created_at, plus its start_time/end_time filter
  bounds so range comparisons stay consistent with the stored UTC instant

Add SQLite regression tests covering non-UTC timezones for each store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the sqlalchemy_timezone_roundtrip branch from b98ebeb to 68222ce Compare June 19, 2026 21:51
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed in 14 days if no further activity occurs. If you are still working on this, please push a commit or leave a comment. Reviewers: please respond, or add the keep-open label if this PR should be held open for a longer review cycle.

@github-actions github-actions Bot added the Stale label Aug 4, 2026
@edwinyyyu edwinyyyu added the keep-open Prevents the auto-close task from closing this issue. label Aug 4, 2026
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit: the comparison side needed the same normalization as the write side.

Storing normalizes to UTC because SQLite's DateTime(timezone=True) does not persist tzinfo. Filtering did not: an aware value bound as-is is rendered with its own offset and compared against the stored text lexically, so the wall clock decides. timestamp <= date('2024-01-01T08:00:30+08:00') excluded a row stored at 2024-01-01T00:00:30Z — thirty seconds earlier than the bound.

_compile_column_leaf now normalizes aware datetimes to UTC, the same conversion the write path and the typed-JSON comparison path already do. PostgreSQL compares TIMESTAMPTZ by instant and is unaffected, so this makes the two backends agree rather than changing Postgres.

Test asserts that one instant spelled in three different zones selects the same rows, parametrized over both backends.

The write path normalizes a timestamp to UTC before storing it, because
SQLite's DateTime(timezone=True) does not persist tzinfo. The comparison
path did not do the same: an aware value bound as-is is rendered with its
own offset and compared against the stored text lexically, so the wall
clock decides. `timestamp <= date('2024-01-01T08:00:30+08:00')` excluded a
row stored at 2024-01-01T00:00:30Z, which is thirty seconds EARLIER than
the bound.

Normalize aware datetimes to UTC in the column comparison path, matching
what the write path and the typed-JSON comparison path already do.
PostgreSQL compares TIMESTAMPTZ by instant and is unaffected, so this
makes the two backends agree rather than changing Postgres.

Test asserts the same instant spelled in three zones selects the same
rows, and runs against both backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the sqlalchemy_timezone_roundtrip branch from cab92b9 to 2d1529d Compare August 10, 2026 20:33
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Aug 11, 2026
Expansion needs the segments AROUND an address, but the only method that could
reach them spliced the seed into its result. That forced a choice with no good
answer once a filter was in play: return the seed even though it failed the
filter, or drop the whole neighbourhood because its centre did. Both are wrong,
and the confusion belongs to the caller's rendering, not to the store.

get_neighbor_segments and get_neighbor_events return the surroundings only. The
caller already holds the seed — it named it — so it can render it, mark it, or
omit it as its own display concern. Neither method can ever return a seed that
failed the filter, because neither returns the seed at all.

Both reuse the existing lateral/loop split rather than adding a query path, so
the plan is the one get_segment_contexts already had. Measured on both backends
they are not meaningfully slower.

Two correctness fixes ride along, both found while testing the above:

Column-encoded datetime comparisons normalize to UTC before binding. SQLite has
no datetime type and compares the stored ISO string, so a tz-aware bound and a
stored naive-UTC value compared as text — the same bug as upstream MemMachine#1462.

is_gapped compared only segment.index, but chunks of one event share an index
and differ by offset, so every within-event gap read as contiguous. It now
compares (index, offset) as the adjacency it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

keep-open Prevents the auto-close task from closing this issue. Stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant