Skip to content

fix(api): order same-instant audit events by a durable monotonic key, not a random Guid #508

Closed

Description

@mforce

Split out of the #494 review (PR #503, codex round 3). Accepted there as a documented residual because the fix needs a migration, which #494 was specified not to do.

The defect

AuditEventRepository.GetProvenanceChunkAsync picks the last change with:

ORDER BY e."EntityId", e."OccurredAtUtc" DESC, e."Id" DESC

AuditWriter mints Guid.NewGuid() — a random v4 — so "Id" carries no chronology. When two reportable changes for one entity share an OccurredAtUtc, the tiebreak resolves arbitrarily and the wrong actor can be named as the last changer. The timestamp displayed is correct either way; only the name is at risk.

ListAsync uses the same tiebreak. There it is merely arbitrary rather than wrong — paging only needs a deterministic order — but the Audit page can still display a same-instant pair in the wrong order.

Why it is reachable, not theoretical

Most concurrent changes to one record cannot collide: the aggregate Version concurrency token serialises them, and the loser gets a 409 having written nothing (audit shares the transaction).

RecordBirdMovementHandler escapes that argument. It writes an audit event keyed to the flocks id while only inserting a BirdMovement row — it never mutates Flock, so no Version bump, so it does not serialise against Flock.Update. Two such requests can land in the same microsecond against the same flock.

What is needed

A durable monotonic ordering value on AuditEvents — a bigint GENERATED ALWAYS AS IDENTITY or a sequence-backed column — used as the tiebreak in both queries.

Note the alternatives that do not work, so they are not re-proposed:

  • UUIDv7 for new ids — only orders to millisecond granularity and keeps a random tail, so it does not resolve a microsecond collision; and existing rows are v4, so a mixed table orders meaninglessly.
  • ctid — physical location, not durable across VACUUM FULL.

Cost

A migration (#407: its own dotnet ef migrations add, never folded into InitialCreate), a docs/schema/ regeneration committed in the same PR (#417), and a sim-harness pass (#370).

Deliberately not done in #503

No test pins the current arbitrary outcome — pinning a known loss turns "did not fix" into "spec". The behaviour is recorded in a comment at the query and here.

Activity

  1. mforce commented on Aug 11, 2026

    @mforce
    OwnerAuthor

    Findings from reading the code after #503 merged (4ffa7f17)

    Four things the issue body should carry before anyone picks this up.

    1. Raising the timestamp's precision is a non-fix — add it to the list of rejected alternatives

    OccurredAtUtc is already timestamptz, which Postgres stores at microsecond precision, and AuditWriter reads clock.UtcNow per call rather than once per unit of work. So the collision is not "the column is too coarse". Two writers can legitimately produce the same microsecond, and no number of extra digits creates an ordering between them — only a shared monotonic counter does. Belongs beside UUIDv7 and ctid in What is needed, because "just store a finer timestamp" is the first thing anyone will reach for.

    2. There are FIVE tiebreak sites, not two

    The body says "both queries". AuditEventRepository actually has five, and a fix that updates fewer leaves the defect live in whatever it skips:

    Line Site Collision reachable?
    :33 ListAsync (LINQ, the admin /audit viewer) yes — display order only
    :96 created no — at most one .Create per entity
    :223 creator CTE no — same reason
    :241 outer last-change yes — this is the defect
    :265 promoted no — neither aggregate has a path back to Draft

    Only two can actually resolve wrongly today, but all five should move to the new column: the three that cannot collide are not guaranteed not to, they are merely unreachable given today's actions, and leaving a random-Guid tiebreak behind re-creates the defect the moment that changes.

    3. Prefer GENERATED BY DEFAULT AS IDENTITY over GENERATED ALWAYS

    The body specifies ALWAYS. BY DEFAULT is the better choice here: ALWAYS rejects an explicit value outright, which blocks any future backfill or data migration that needs to preserve an order it computed. Nothing in the app writes the column either way — EF marks it ValueGeneratedOnAdd and never sends it — so ALWAYS buys no real protection while costing that escape hatch.

    4. The migration rewrites the table — which argues for doing it now, not later

    ALTER TABLE … ADD COLUMN … AS IDENTITY has a sequence-backed default, so Postgres cannot use the fast constant-default path: it rewrites the whole table and takes an ACCESS EXCLUSIVE lock for the duration. Two consequences:

    • Existing rows get their values in heap order, which is not insert order after any update or vacuum — i.e. arbitrary. That is not a regression (those rows are arbitrary today), but nobody should read the backfilled values as historical truth.
    • It is cheap now, while AuditEvents is small and pre-1.0, and it only gets more expensive. If it does wait until the table is large, the shape becomes: add a nullable bigint with a sequence default, backfill in batches ordered by (OccurredAtUtc, Id), then SET NOT NULL — more steps, no rewrite lock.

    One caveat to state in the fix, not discover later

    A sequence gives insert order, not commit order. Two concurrent transactions can take sequence values in one order and commit in the other. That is still strictly better than a random v4 and correct for every case the History column cares about — but the fix should not claim to establish a true total order for auditing purposes, or the next person will rely on something it does not provide.

    Confirmed non-impacts

  2. added
    severity:p2Defect: user-visible wrong behaviour, no data loss
    on Aug 31, 2026
  3. added 3 commits that reference this issue on Sep 6, 2026
  4. mforce commented on Sep 6, 2026

    @mforce
    OwnerAuthor

    Fixed and merged in #700 — squash commit 8fcf084fb3889b78e9bee950f6e324d7e0f238dc on main.

    What shipped. AuditEvents gained a bigint GENERATED ALWAYS AS IDENTITY column, Sequence, mapped as an EF shadow property so it reaches no API response, export or screen. It replaces Id as the tiebreak in five order clauses — ListAsync, and created, creator, latest, promoted in GetProvenanceChunkAsync. The issue only named two; a pre-dispatch review refuted the reasoning that the other three were harmless (their cardinality holds by the aggregate state machine, never by anything the audit layer enforces), so all five moved.

    The migration is hand-written, not the EF scaffold. The scaffolded one-liner backfills existing rows in physical order. For this append-only heap that is insert order today, but it is not a contract — a VACUUM FULL, CLUSTER or pg_repack rewrite may reorder it. The shipped Up() adds the column nullable, backfills with row_number() OVER (ORDER BY "OccurredAtUtc", "Id"), sets NOT NULL, attaches the identity, then advances the sequence. Verified against a real Postgres, including the empty-table case every Testcontainers run hits. It rewrites the table under ACCESS EXCLUSIVE — so does the one-liner, since an identity column assigns per-row values either way — and migrate runs as a pre-deploy job.

    Two regression tests, one per surface, both pinning their Guids so the old behaviour fails deterministically rather than about half the time. Reverting the fix with the tests kept reproduces the original symptom exactly: Expected: "second@farm.test" / Actual: "first@farm.test".

    The alternatives this issue ruled out stayed ruled out — no UUIDv7, no ctid, no change to AuditWriter's random id.

    Acceptance criteria the fix deliberately does NOT meet, so nobody assumes otherwise:

    1. Legacy rows. For rows written before this migration, write order is not recoverable. Same-timestamp legacy ties are ordered by row_number() over a random Guid — deterministic and stable, but arbitrary, exactly as before. The guarantee holds for rows inserted after the migration.
    2. Clock rollback. OccurredAtUtc is still the primary sort. A backwards clock step can give a later insert an earlier timestamp, and Sequence is then never consulted. Closing that means redefining "latest" as insert order for every pair, which is a larger behaviour change than this issue scoped. Recorded, not filed — the owner chose to leave it documented.

    Both are written up in docs/plans/508-audit-monotonic-order/01-diagnosis.md, which ships with the change, along with the 38 sibling ThenBy(x => x.Id) sites (none a wrong value today) and the export's missing ordering key (input for the unshipped legacy-import work).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    epic-1.5severity:p2Defect: user-visible wrong behaviour, no data loss

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions