Skip to content

now() at time zone 'utc' written into timestamptz columns skews mt_last_modified and mt_events.timestamp by the server's UTC offset #5136

Description

@jeremydmiller

Three write paths store now() at time zone 'utc' into a timestamp with time zone column. That expression strips the offset to produce a naive timestamp holding UTC wall-clock time, and assigning a naive timestamp to a timestamptz re-interprets it in the session's TimeZone. On any database whose TimeZone is not UTC the stored instant is wrong by the UTC offset — it is a genuinely different point in time, not a display artifact.

The matching INSERT defaults ((now()), (transaction_timestamp())) are correct, so a row is stamped correctly on insert and then jumps by the offset the first time it goes through one of these paths.

Reproduction

create database tz_probe;
alter database tz_probe set timezone='Europe/Berlin';
\c tz_probe

create table probe(seq bigserial primary key, ts timestamp with time zone not null default (now()));

insert into probe(ts) values ((now() at time zone 'utc'));  -- what the write paths below do
insert into probe(ts) values (default);                     -- what the column DEFAULT does

select seq, ts, now() as server_now, now() - ts as reads_this_far_in_the_past from probe order by seq;
 seq |               ts              |          server_now           | reads_this_far_in_the_past
-----+-------------------------------+-------------------------------+----------------------------
   1 | 2026-08-03 13:41:09.340771+02 | 2026-08-03 15:41:09.340771+02 | 02:00:00
   2 | 2026-08-03 15:41:09.340771+02 | 2026-08-03 15:41:09.340771+02 | 00:00:00

Two hours in the past on a UTC+2 database. West of UTC it skews the other way (America/Chicago → five hours in the future).

Affected sites

Site Column Impact
LastModifiedColumn.cs:27WriteMetadataInUpdateStatement mt_last_modified (timestamp with time zone, default (transaction_timestamp())) Every document UPDATE. Broadest blast radius: IDocumentMetadata.LastModified, ModifiedSince/ModifiedBefore queries, and anything auditing document freshness. Inserted rows are correct; the value shifts on first update.
QuickAppendEventFunction.cs:69 mt_events.timestamp (timestamp with time zone) AppendMode.Quick only. IEvent.Timestamp, AggregateStreamAsync(streamId, timestamp:), any time-bounded event query or archiving-by-age policy. mt_streams.timestamp in the same function correctly uses bare now().
ReplaceEventOperation.cs:47 mt_events.timestamp Event-data-protection rewrites re-stamp the event with a skewed timestamp regardless of append mode.

EventAppendMode.Rich (the default) and QuickWithServerTimestamps bind a client DateTimeOffset, which is unambiguous — those paths look correct.

Why it is reachable

Neither Marten nor Npgsql pins the session TimeZone. Npgsql exposes an opt-in Timezone connection-string parameter; when it is not supplied the session inherits the server/database TimeZone GUC. Managed Postgres offerings commonly default to UTC, but on-prem installs and initdb on a non-UTC host frequently do not, and ALTER DATABASE … SET TimeZone is a normal thing for an operator to do.

I have verified the SQL semantics and the code sites above, but have not run an end-to-end Marten test against a non-UTC-TimeZone database — worth confirming before treating the fix as mechanical, in case something upstream pins the session timezone that I have missed.

Suggested fix

Replace all three with bare now() (or transaction_timestamp(), matching the corresponding column DEFAULT). No schema migration is needed and no round-trip behavior changes on a UTC database, so this should be safe to backport.

Worth pinning with a regression test that runs against a database with a non-UTC TimeZone — asserting that a document's LastModified after an update, and a Quick-appended IEvent.Timestamp, are both within a small window of now().

Why this surfaced

Noticed while reviewing #5109, which proposes computing a stuck-gap age as transaction_timestamp() - mt_events.timestamp to bound the SkipStaleGapsDespiteLiveTransactionsAfter cap. That arithmetic inherits this skew directly: on a UTC+2 database in Quick append mode every gap would read two hours older than it is, collapsing a configured cap into "skip as soon as StaleSequenceThreshold passes" and dropping events from live appends. That PR is a separate discussion (see #5108, #5125), but it should not land on top of this column until the column is trustworthy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions