Skip to content

v42.1.0 — a dimension prefix filter compares BYTES: two backends disagreed, and every dimension read scanned the corpus (#817, #818) - #820

Merged
emooreatx merged 3 commits into
mainfrom
fix-817-818-dimension-seek-and-case
Sep 8, 2026
Merged

v42.1.0 — a dimension prefix filter compares BYTES: two backends disagreed, and every dimension read scanned the corpus (#817, #818)#820
emooreatx merged 3 commits into
mainfrom
fix-817-818-dimension-seek-and-case

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Closes #817
Closes #818

Two issues, one edit. A dimension prefix filter now compares bytes on every backend, which is simultaneously the correctness fix (#818) and the performance fix (#817).

#818 — the same filter returned different rows per backend

dimension_prefixes compiled to LIKE on both SQL backends. SQLite's LIKE is case-INsensitive for ASCII; Postgres' is case-sensitive; the memory backend folds with str::starts_with. So one AttestationFilter answered differently depending on what was underneath.

v42.0.0 ratified CC 3.1.7 R3 — a dimension is a case-sensitive byte string, enforced at the write door by check_dimension_case_rule. So sqlite was the one that was wrong: it admitted approach:goalx:v1 and approach:GOALX:v1 as distinct rows and then matched them as if they were the same.

Reachable, not theoretical. R3 exempts Value/External/Wildcard segments from the lowercase rule because they carry caller data, and 32 catalogued families have such a segmentapproach:{goal_id}, delivery_receipt:{stream_id}, bond_posted:{currency}, content_rating:{scheme}:{rating}, and more. The witnesses use a pair both admitted by the current door rather than a constructed one.

I corrected the issue body: my original CONFIG:X example would be refused by the v42 write door (uppercase family stem), so it was a bad illustration of a real bug.

The defect was on two handles with different backend counts, and fixing only the one #817 named would have left half of it live:

handle memory sqlite postgres
list_scores starts_with LIKEoutlier of 3 LIKE
list_attestations not implemented LIKE LIKE

#817 — dimension reads were O(rows the node authored)

list_attestations compiled both dimension axes to a per-row json_extract(attestation_envelope, '$.dimension'), so a read for one dimension JSON-parsed every row its attester had ever written. CIRISServer#557 measured 334k pread64 in 25 s and 20 s of a core, per poll cycle.

V106 had already added a generated dimension column for exactly this, and nothing ever used it. So there is no rebuild on either dialect — V137 is one CREATE INDEX and the builders now read the column. Pinned by an EXPLAIN plan assertion rather than a timing one:

dimension  = ?                     -> SEARCH ... USING INDEX (attesting_key_id=? AND dimension=?)
dimension >= ? AND dimension < ?   -> SEARCH ... USING INDEX (attesting_key_id=? AND dimension>? AND dimension<?)

LIKE never got the index anyway: sqlite categorically declines its LIKE-to-range optimization when an ESCAPE clause is present, and our builders always emitted one.

Two traps, either of which would have shipped a silent wrong answer

1. Postgres >=/< are not byte order. They use the database collation; ours is en_US.utf8, which is linguistic. The naive port of the sqlite range makes the prefix filter return nothing at all — not a few rows short, empty — because 'config:' and 'config;' collate adjacently when punctuation is weighted weakly. The predicate says COLLATE "C" and V137 indexes that same expression. They must agree in both directions: a collated predicate against a default-collated index is correct-but-unindexed; a default-collated predicate against the collated index is an index scan returning wrong rows.

2. Routing through attestation_subjects — the approach #817 proposed — is a correctness regression. That projection is subject-keyed by construction (V106's backfill emits nothing for an empty subject_key_ids), while list_attestations is not subject-keyed and subjectless attestations are routine. A JOIN would have silently dropped every one of them. list_scores may use the projection; list_attestations may not.

Witnesses — six, three mutation-verified

A witness for this class that stays green on the old builder is measuring the wrong thing, so each was run against the pre-fix code:

witness mutation result
sqlite_dimension_prefix_is_case_sensitive_818 LIKE["l","u"] vs ["l"] red
sqlite_scores_dimension_prefix_is_case_sensitive_818 LIKE → both ids red
pg_dimension_prefix_is_case_sensitive_818 drop COLLATE "C"left: [] red
memory_scores_dimension_prefix_is_case_sensitive_818 pins the backend that was already right
sqlite_dimension_filters_are_index_served_817 EXPLAIN plan assertion
dimension_prefix_bounds_tests (4) range-is-exactly-the-prefix-set, spelled as membership over neighbours

Test state — stated plainly

  • sqlite lib suite: 2272 passed, 0 failed (complete run).
  • postgres lib suite: 7 failed. One was mine — the version bump unpinned 80 evidence/cc_impl.tsv rows; re-stamped and verified. The other six are Six postgres quorum tests pass alone and fail together — the strict-majority denominator N is the process-wide steward roster, so the fixture's 2-of-2 becomes 2-of-33 #819, pre-existing: they pass individually and fail together because the strict-majority denominator N is the process-wide steward roster, so a fixture's 2-of-2 becomes 2-of-33. Reproduced on a tree byte-identical to main, and the affected path contains no dimension filter at all.
  • Full certification has NOT run yet. It refused with RC=2 on the co-tenant guard — two other repos are building against the shared 32 cores and the single postgres cluster. I am not overriding that guard; it exists because a suite once went red for a reason that was not in the tree. Queued for a clear window, and I will post the verdict here.

Not claiming a performance number until it is measured on a corpus shaped like the status node's.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QnaPX9k2t3SeLKXqEXtTGj

emooreatx and others added 2 commits September 8, 2026 09:50
… disagreed, and every dimension read scanned the corpus

Closes #817
Closes #818

#818 — the same `AttestationFilter` returned different rows per backend.
`dimension_prefixes` compiled to `LIKE` on both SQL backends; sqlite's LIKE is
case-INsensitive for ASCII, postgres' is case-sensitive, and the memory backend
folds with `str::starts_with`. v42.0.0 ratified CC 3.1.7 R3 — a dimension is a
case-sensitive byte string, enforced at the write door by
`check_dimension_case_rule` — so sqlite was the one that was wrong, matching
`approach:GOALX:v1` for an `approach:goal` prefix.

Reachable, not theoretical: R3 exempts Value/External/Wildcard segments from the
lowercase rule because they carry caller data, and 32 catalogued families have
such a segment. The witnesses use `approach:goalx:v1` vs `approach:GOALX:v1`,
both admitted by the current door.

The defect was on TWO handles with different backend counts: `list_scores`
(three backends, sqlite the outlier) and `list_attestations` (two — the memory
backend returns `memory_read_unsupported`). Fixing only the handle #817 named
would have left half of it live.

#817 — `list_attestations` compiled both dimension axes to a per-row
`json_extract(attestation_envelope, '$.dimension')`, so a read for one dimension
JSON-parsed every row its attester had ever written. V106 had already added a
generated `dimension` column for exactly this and nothing ever used it. V137
indexes it; no table is rebuilt on either dialect because the column was already
there.

TWO TRAPS, either of which would have shipped a silent wrong answer
------------------------------------------------------------------

1. Postgres `>=`/`<` use the DATABASE collation, not byte order. Under
   `en_US.utf8` the naive port of the sqlite range returns NOTHING AT ALL — not
   a few rows short, empty — because 'config:' and 'config;' collate adjacently
   when punctuation is weighted weakly. The predicate says `COLLATE "C"` and
   V137 indexes that same expression. They must agree in both directions: a
   default-collation predicate against the collated index is an index scan
   returning WRONG ROWS.

2. Routing through `attestation_subjects` — the approach #817 proposed — is a
   correctness regression. That projection is subject-keyed by construction
   (V106's backfill emits nothing for an empty `subject_key_ids`), while
   `list_attestations` is not subject-keyed and subjectless attestations are
   routine. A JOIN would have silently dropped every one of them.

WITNESSES — six, three mutation-verified against the pre-fix builders
--------------------------------------------------------------------
A witness for this class that stays green on the old code is measuring the
wrong thing, so each was run against it:

  sqlite_dimension_prefix_is_case_sensitive_818        LIKE mutation -> ["l","u"] vs ["l"]
  sqlite_scores_dimension_prefix_is_case_sensitive_818 same, scores handle
  pg_dimension_prefix_is_case_sensitive_818            COLLATE mutation -> left: []
  memory_scores_dimension_prefix_is_case_sensitive_818 pins the already-correct backend
  sqlite_dimension_filters_are_index_served_817        EXPLAIN plan, not a timing assertion
  dimension_prefix_bounds_tests (4)                    the range-is-the-prefix-set property

`LIKE` is no longer used for a dimension prefix on either backend. Do not
reintroduce it, and do not "fix" the case axis with a COLLATE NOCASE index —
that encodes the R3 violation into the schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnaPX9k2t3SeLKXqEXtTGj
…— and the failure could not say so

CI went red on `linux-x86_64 (cirisaudit)` for this branch with:

    could not provision a per-process database (execute: db error).
    SQL: CREATE DATABASE "ciris_t_11560_…" TEMPLATE "ciris_t_template_449d35e4185d59a2"

Two separate defects, and the second is why the first took a log dig to reach.

1. EXISTENCE ARRIVED BEFORE READINESS
-------------------------------------
`ensure_template` created the template UNDER ITS FINAL NAME and migrated it
afterwards, so for the length of a full migration run the name existed while the
schema did not. Its own fast path tests existence, so every other process in
that window skipped the advisory lock entirely and issued
`CREATE DATABASE … TEMPLATE <half-built>`.

What CI SAW was postgres refusing the copy because the builder was still
connected — a red leg, which is the LUCKY outcome. The unlucky one is the copy
succeeding against a partially migrated template: every per-test database then
carries a schema the tree does not describe and the suite goes green on it.
That is precisely the silent-wrong-answer the fingerprinted template name exists
to prevent (it bit both directions while landing V121) — reintroduced one level
up, in the lifecycle rather than in the name.

Fixed by building under a scratch name and RENAMING into place, so the name
means "fully migrated" by construction rather than by timing. The scratch is
`ciris_t_<pid>_tpl_<hash>`, deliberately shaped like a per-process database:
`reap_dead` parses the segment after `ciris_t_` as a PID, so a dead builder's
scratch is swept, while `ciris_t_template_<hash>` fails that parse and correctly
survives as the cache it is.

I got that naming backwards on the first pass (`<template>_bld_<pid>`, which
`reap_dead` can never match) and wrote a comment claiming the reaper handled it.
`scratch_and_template_names_sort_correctly_for_the_reaper` now pins both halves.

WHY THIS BRANCH AND NOT ANOTHER: the hazard is latent on every migration-adding
PR. V137 changed the migration fingerprint, which invalidates the cached
template and forces every CI process to race a fresh build. Nothing about the
dimension work is involved.

REPRODUCTION — STATED HONESTLY: I could not reproduce this locally. Sixteen
staggered followers across two rounds, against a deliberately dropped template,
all passed on the OLD code. The local migration window is ~0.6s; CI's, under a
2992-test parallel leg, is far wider. So the fix is argued from the ordering and
the error signature, NOT from a local repro that went red and then green.

2. THE FAILURE COULD NOT NAME ITS CAUSE
---------------------------------------
`tokio_postgres::Error`'s Display is the literal string "db error". The server's
message, SQLSTATE, detail and hint all live on the `DbError` behind
`as_db_error()` and were being thrown away, so the panic named the SQL that
failed and not one word about why. That is what made (1) a log-archaeology job,
and it is why the repro above matters less than it should.

`describe_pg_error` now renders, e.g.:

    execute: database "template1" already exists [SQLSTATE 42P04]

Witnessed by `a_refused_statement_names_its_reason_not_just_db_error`, which
drives a real refusal rather than asserting on a constructed string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnaPX9k2t3SeLKXqEXtTGj

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0c200322bb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/test_pg.rs Outdated
Comment on lines +602 to +604
assert!(
err.contains("already exists"),
"provisioning error must carry the server's message, got: {err}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Assert the duplicate-database SQLSTATE instead of English text

When the PostgreSQL test server uses a non-English lc_messages, the duplicate-database error is localized, so the operation still correctly returns SQLSTATE 42P04 but this assertion fails because the message no longer contains already exists. Assert the specific SQLSTATE already included by describe_pg_error rather than an English rendering so the postgres suite remains portable across server locales.

Useful? React with 👍 / 👎.

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.

Accepted and fixed in 127fbe9. You are right: that assertion tests postgres's locale, not our code.

The test now asserts the SQLSTATE, which is the stable half and the one describe_pg_error already carries:

assert!(err.contains("42P04"), "... got: {err}");

I kept a second, weaker assertion that some server message came through — a length floor past "execute: db error" plus the SQLSTATE — because carrying the server's message is the whole point of the change, and asserting only the code would let a regression that drops the message go green. But the message's content is the server's to choose, so nothing asserts on its wording any more.

Comment thread src/test_pg.rs
Comment on lines +244 to +246
let scratch = scratch_template_name();
let _ = run_sql(admin, &format!("DROP DATABASE IF EXISTS \"{scratch}\""));
run_sql(admin, &format!("CREATE DATABASE \"{scratch}\""))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the advisory-lock session alive while building the template

On a cold parallel nextest start, each process can enter this scratch creation and run the full migration set concurrently: with_advisory_lock acquires the session lock in its spawned thread, then returns from the async block and drops that session before invoking f at line 329. Because these new scratch names are PID-specific, the builders no longer collide early, so all initial processes can perform 137 migrations, losers can leave renamed-failure scratch databases behind, and the test cluster can be overwhelmed by the exact migration and disk load the shared template is meant to avoid. Keep the lock-holding client alive until the scratch migration and final rename finish.

Useful? React with 👍 / 👎.

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.

Accepted, reproduced, and fixed in 127fbe9. This is the right call and it is worse than described, because my own previous commit is what turned it live.

You are right that the lock was never held. with_advisory_lock took pg_advisory_lock on a connection owned by a thread that then returned; the session closed and the lock dropped before f() was ever invoked. The function's own comment said so and argued the window was harmless because f re-checks existence.

That argument held only by accident, and I removed the accident. Previously every builder raced CREATE DATABASE "<final template name>" — postgres let exactly one win and the losers fell back to the slow-but-correct path. The PID-unique scratch name I introduced to fix the readiness race means every CREATE DATABASE now succeeds, so on a cold parallel start every process runs the full migration set at once, and the rename losers leave migrated scratch databases standing. Exactly your two consequences.

So the two fixes are coupled in a way I had missed: build-then-rename is only safe if the lock is real.

Fix. The lock-holding connection stays open until f has finished and the rename has landed. One detail worth naming: the release signal is a tokio::sync::oneshot that the lock thread .awaits, deliberately not a blocking recv — the current-thread runtime has to keep polling the connection future or the client stops being driven while the caller runs 137 migrations on the other side of the channel.

Measured rather than argued. the_lock_is_held_for_the_whole_closure_not_just_acquired sends four threads through one lock and records the peak occupancy of the critical section:

shape peak
with the fix 1
release-early (the original) 4left: 4, right: 1

That mutation is the evidence the preceding commit lacked. I could not reproduce the template readiness race locally (16 staggered followers, all green on the old code — the local migration window is ~0.6s against CI's under a 2992-test leg), and I said so in the PR. This one reproduces on demand, which is a much better place to be.

Also closed the loser-path gap you named: a failed RENAME now drops its scratch rather than leaving a migrated database for reap_dead to collect only after the process exits.

Cold-start verified end to end — template dropped, six concurrent builders, 6/6 pass, zero leftover scratch databases.

Thank you. This was a real hole, and the fact that the broken lock had been serializing nothing since it was written — with a comment explaining why that was fine — is the part I would not have gone looking for.

…s meant to guard — Codex review P1/P2

Both findings from Codex's review of PR #820 accepted, reproduced, and fixed.
The P1 is the more serious of the two, and my own previous commit is what turned
it from dormant to live.

P1 — `with_advisory_lock` serialized NOTHING
--------------------------------------------
It took `pg_advisory_lock` on a connection owned by a thread that then RETURNED.
Advisory locks are session-scoped, so the session closed and the lock dropped
before `f()` was ever called. The function's own comment stated this and argued
the window was harmless because `f` re-checks existence.

That argument held only by accident. Every builder raced
`CREATE DATABASE "<final template name>"`; postgres let exactly one win and the
losers took the slow-but-correct path. Giving each builder a PID-unique scratch
name — the previous commit's fix for the readiness race — removed that
accidental serialization. Every `CREATE DATABASE` then succeeded, so on a cold
parallel start EVERY process ran the full migration set simultaneously, which is
exactly the load the shared template exists to prevent, and the losers of the
rename left migrated scratch databases standing.

So the two fixes are coupled: build-then-rename is only safe if the lock is
real. The lock-holding connection now stays open — and its runtime keeps
POLLING it, via `oneshot` + `.await` rather than a blocking recv, or the client
stops being driven while the caller runs 137 migrations — until `f` has finished
and the rename has landed.

MEASURED, not argued. `the_lock_is_held_for_the_whole_closure_not_just_acquired`
runs four threads through one lock and records the peak occupancy of the
critical section:

    with the fix:        peak = 1
    release-early shape: peak = 4   (left: 4, right: 1)

That is the mutation check, and it is the evidence the previous commit lacked:
I could not reproduce the template race locally, but this one reproduces on
demand.

Also closed the gap Codex named in the loser path: a failed RENAME now drops its
scratch instead of leaving a migrated database for `reap_dead` to collect only
after this process exits.

P2 — an assertion on English prose is a portability bug
-------------------------------------------------------
`a_refused_statement_names_its_reason_not_just_db_error` asserted the rendered
error contains "already exists". A server with a non-English `lc_messages`
localizes that string while still returning SQLSTATE 42P04, so the test would
fail on a correct server. It now asserts the SQLSTATE, plus a length floor that
still proves a server message came through — the CONTENT of that message is the
server's to choose, so asserting on it was testing postgres's locale, not our
code.

Cold-start verified end to end: template dropped, six concurrent builders, 6/6
pass, zero leftover scratch databases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnaPX9k2t3SeLKXqEXtTGj
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx

Copy link
Copy Markdown
Contributor Author

Both gates green on 127fbe9

Gate 1 — local certification: 33 legs green BY EXIT CODE, CERTIFY_RC=0, SCRIPT_EXIT=0, zero red. Every feature set run separately (never a union), plus the full 15-leg axis-* backend matrix — none / sqlite / pg for each of cirisaudit, secrets, cirisnode, cirisgraph, telemetry.

core 2853 · cirisaudit 2960 · secrets 2912 · cirisnode 3014 · cirisgraph 2884
telemetry 2918 · rest 3457 · test-anchor 2338 · default 1506 · python 47
fmt · clippy · pyi · featmatrix · wheelfeat · docver · pyo3sqlite · dirdouble
+ 15 axis-*-{none,sqlite,pg} legs
wall 1559s at 3 lanes x 10 threads

Run at LANES=3 rather than the auto-tuned 7: the box was under memory pressure and two earlier attempts were killed by the watchdog before they got past the lane calculation. Fewer lanes, same legs, longer wall clock.

Gate 2 — CI: green at JOB level on this exact SHA. Run 34252775707: 21 success, 2 structural skips (tag-gated upload; cache-warm-only darwin-x86_64), zero failures.

The leg that matters most here is linux-x86_64 (cirisaudit)success. That is the one that was red on 141d973, and it failed on the template-provisioning race — a load-dependent failure I could not reproduce locally across 16 staggered followers. CI running it green under the same 2992-test parallel conditions that broke it is the only direct evidence available that the fix holds, and it is now on record rather than argued.

Review: both Codex findings accepted, reproduced, fixed and replied to. The P1 (advisory lock released before its closure) is mutation-pinned at peak occupancy 1 vs 4.

Ready to merge on the maintainer's call.

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