Skip to content

Clear a removed table TTL everywhere, reject invalid TTLs where they are declared, and scan catalog-loaded tables without a write - #4250

Merged
kriszyp merged 11 commits into
mainfrom
fix/table-ttl-config-clear-validate-arm
Nov 11, 2026
Merged

kriszyp merged 11 commits into
mainfrom
fix/table-ttl-config-clear-validate-arm

Conversation

@kriszyp

@kriszyp kriszyp commented Nov 11, 2026 •

Copy link
Copy Markdown
Member
Kris's daughter pries an old compost-by date off a signpost while a robot refuses a minus-one sign.

⊙ Problem

A table's saved expiration and eviction had four gaps:

  • Removing one from @table, or setting 0, never cleared it. Records kept expiring, after restarts too.
  • A negative value on a new table failed later, naming no table. create_table ignored eviction.
  • A thread that adopted an eviction-only TTL never scanned for expired rows.
  • Under threads: 0, catalog-loaded tables were not scanned until a write.

🤔 Clearing is a behavior change

An operator who relied on a removed expiration staying in effect must declare it again. Records already written keep the expiry they were given.

💡 Solution

  • A bad TTL value fails with a 400 naming the table and field, before anything changes.
  • @table and defineTable clear what they omit. table() callers such as add_attribute keep it.
  • A TTL from sourcedFrom options survives a schema without one, in isolated applications too.
  • One rule sets the scan interval however a TTL arrives.
  • Under threads: 0, scans start once components load.

⚖️ Alternatives

🤔 A thread keeps the TTL its own declaration set

A thread whose schema declared a TTL ignores a later value another thread saves, until it declares again. Every worker running the same schema re-declares on restart, so this shows only when threads declare differently (#4240).

🤔 A schema 0 counts as declaring no TTL

expiration: 0 leaves an application-set TTL in place, as omitting it does; declareTable cannot tell them apart (#4253).

🤔 A peer's invalid saved TTL fails its create

An older peer's negative or non-numeric TTL fails the create, naming the field; a local load drops it with a warning (#4254).

🤔 A cleared table keeps a daily scan

Rows written before a clear still carry their expiry, so the table keeps a daily cleanup scan, marked expiryScan on its catalog row. The mark is never removed; the alternative was leaving those rows readable and stored forever after a restart.

Other options weighed

Framing-Verdict: better-alternative-exists — resolved by adopting the reviewers' alternatives (strict input types, keeping a saved value the declaration omits, no new cadence helper, arming after components load) and overruling two:

  • Making the main thread worker 0 before startup under threads: 0: startup runs upgrade directives, compactOnStart and migrateOnStart first, and scans would run during them.

  • Sending a cleared TTL to cluster peers: an existing table never takes a peer's TTL, set or cleared.

  • Validate only in the operations API and GraphQL: misses defineTable and application table() calls.

  • Treat only an explicit 0 as clear: removing a value from a schema would still do nothing.

  • Arm threads: 0 scans right at promotion: a stale saved TTL would start evicting while the schema that removes it is still loading.

🔧 Changes

Declaration Before After
@table / defineTable without expiration saved value kept cleared
expiration: 0 or null saved value kept cleared
table({ expiration }) without eviction (add_attribute, cluster events) eviction kept eviction kept
expiration: -1, 'soon', true -1 failed naming no table; 'soon' and true were accepted 400 naming table and field, nothing changed
create_table with eviction ignored saved

Files

Tour: which declarations set, clear or keep a TTL, which thread holds which, and when scans arm

Product and architecture tour

What a declaration does to a saved TTL

Which declarations set, clear or keep a table's saved TTL?

declareTable checks each value with ttlSecondsOf before anything is opened or saved, so a bad value leaves no table and no catalog row behind. @table passes null for what it omits, and so does defineTable. persistTableSettings deletes a cleared field. A table() call that names only one field keeps the other as the row it writes holds it, re-read under the schema lock — look hardest here: that row is also what the declaring thread applies.

Which TTL each thread holds

Which value wins when threads disagree?

A thread that declared the table, or whose application code set its TTL, keeps that TTL until it declares again.
Every other thread, and every restart, takes the saved value.
A schema that declares no TTL does not clear one application code set, in an isolated application too; it does clear a TTL a schema set later.

When the cleanup scan arms

When does each thread start reclaiming expired rows?

From load to scan

  1. Load — a saved TTL derives the interval: scanInterval, else (expiration + eviction) / 4, else daily.
  2. Adopt — a reload that finds a changed row, cleared or eviction-only included, goes through the same setter.
  3. Clear — stamped rows keep their expiry; the scan moves to daily and the row records expiryScan.
  4. Promote — under threads: 0, after components load, armTableCleanupScans arms each loaded table that has a TTL or expiryScan.

✅ Verification

  • QA-5424 (an isolated application's schema zeroing a sourcedFrom TTL): four new unit cases fail at 695e719 and pass here, on RocksDB and LMDB.
  • The new integration test passed 12/12 at 695e719 and fails on main; this head's change is unit-covered, and CI re-runs it.
  • 28 new unit cases green on both engines; the TTL integration set passed 20/20 at 695e719.
Test details
Test fixtures

Its fixture's TtlControl pins declarations to one worker so the scan owner learns TTLs only from the catalog; schema.graphql and config.yaml declare the TTL the test removes; NegativeTtl (config) checks the failure names table and field.

The three four-worker cases that must reach a chosen worker skip Windows and Bun, which do not spread connections across workers.

Full test:unit:resources run

6669 passing. The same 101-test ConflictRetryTable/ cascade and originClosedFloor clock failure occur on a main build. Three replica-pressure timing assertions failed in the full run; interleaved, replicaStorageReclamation.test.js passed 5/5 on both branch and main.

Performance: no record read or write path changes. Validation runs per declaration; socketRouter.ts adds one pass over loaded tables at threads: 0 startup; a cleared table keeps one daily metadata scan.

PR QA: Does an isolated application's table keep the TTL its sourcedFrom options set, whichever plugin loads first, across restarts? — queued @ 51a5a77, qa-pr-harper-4250-51a5a77

Docs: in this PR.

🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @kriszyp.

🤖 Generated with Claude Code

Release target: v5.4 — a behavior change documented in the 5.4 release notes; the gaps are also on v5.3, where a backport is a customer-need call

Related PRs: #2155 overlaps (both change scheduleCleanup; this removes its zero-interval exit, which that PR rewrites), 6 others independent
Complexity: complicated

Deferred findings

  • b8406dd465c94218 minor correctness — resources/Table.ts — explicit schema zero preserves application-set TTL — filed A schema TTL of 0 leaves an application-set TTL in place, like an omitted one #4253
  • 61d6fd7c994922b5 minor reliability — resources/Table.ts — peer definition with legacy invalid TTL now fails table create; catalog load tolerates same value — NOT FILED
  • ac1c5dc3bd820ff9 nit correctness — dataLayer/schemaDescribe.ts — eviction-only table described as expiration 0s, eviction unreported — filed describe_table reports expiration 0s for an eviction-only table and never reports eviction #4242
  • 9ee5a87fa17da5dd nit maintainability — server/threads/socketRouter.ts — suggest comment on why multi-worker path needs no global arm — NOT FILED
  • 27445315efa84340 nit maintainability — integrationTests/database/table-expiration-config.test.ts — added comments narrate setup, identifiers, arithmetic — NOT FILED
Origin — the dispatch brief this PR was written from

Make a table's saved TTL settings clearable, validated, and armed on every thread that loads them

Dispatch: task proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5 · queued by unknown · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; blocked=gemini(timeout); declined=muse; rounds=4; full=2 @ 51a5a77

Review-Attention: deep ~70m (critical: ResourceBridge.ts, schemaDescribe.ts +2; hot: socketRouter.ts; decisions: sticky-expiry-scan-flag, omission-clears-ttl, application-ttl-provenance, peer-invalid-ttl-policy, do-less-alternative; raised: degraded review) @ 51a5a77

kriszyp and others added 6 commits November 10, 2026 21:12
A table's expiration and eviction are validated where a declaration or create_table names them, a
schema that no longer declares one clears the saved value on every thread and after a restart, the
cleanup scan's cadence comes from one rule for every path that sets a TTL, and under threads: 0 the
scans of catalog-loaded tables arm once the main thread is worker 0, after components load.

- declareTable normalizes expiration, eviction and scanInterval before anything is opened or
  persisted (non-negative, finite, representable numbers or numeric strings; null clears); a bad
  value fails with a 400 naming the table and field. create_table forwards eviction.
- @table and defineTable pass null for an omitted expiration or eviction; persistTableSettings
  deletes a cleared field; a declaration keeps the saved value of a field it omits; a loaded class
  adopts a cleared catalog row; a schema without a TTL leaves one application code configured.
- setTTLExpiration validates every value before changing anything; its shared cadence block falls
  back to the daily default when no TTL remains, instead of disabling the scan.
- Saved values no declaration could save now load as absent with a warning.
- describe_table reports no expiration once a TTL is cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
… bad saved values safely

- A table() declaration naming only one of expiration and eviction now takes the other from the
  catalog row persistTableSettings reads (re-read under the schema lock when it writes), not from
  a read before the lock, so a concurrent declaration's value is not written back stale.
- The warning for an invalid saved TTL renders any value without throwing and is marked sent only
  after it is logged; saved values other than numbers and numeric strings load as absent.
- Docs say which fields a schema clears and which threads adopt the saved value.
- Trimmed comments that narrated the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
…n on create

- A setTTLExpiration call that clears a TTL whose scan was scheduled now arms the daily scan even
  when no timer exists yet, as under threads: 0 when the schema clears a TTL the main thread loaded
  before it became worker 0: rows written under the old TTL keep their expiry and still need it.
- makeTable arms a loaded TTL through the object form, so a peer definition's negative eviction
  fails the create like a negative expiration, instead of giving the scan a negative interval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
…umeric value fails its create

makeTable now arms a loaded TTL through setTTLExpiration whenever expiration or eviction is set to
anything but absent or zero, so a peer definition's NaN, negative or non-numeric value fails the
create with an error naming that field, instead of leaving a NaN expiration on the class. An
eviction-only value is validated as eviction, not as a NaN expiration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
…and maintenance pauses

Rows written under a TTL keep their expiry after the TTL is cleared, and only a cleanup scan
removes them. A declaration that clears a saved expiration or eviction now also marks the catalog
row `expiryScan`; a table loaded from such a row keeps the daily scan a table without a TTL would
not arm, and so does a class that cleared a scheduled TTL when its maintenance resumes or the
main thread becomes worker 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
An existing table class that reloads from a catalog row marked expiryScan now keeps the daily
scan too, whichever thread's declaration owns its TTL, so rows another thread stamped under a TTL
it since cleared are reclaimed before this thread restarts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
@kriszyp kriszyp added this to the v5.4 milestone Nov 11, 2026
kriszyp and others added 4 commits November 11, 2026 00:09
They reach a chosen worker by retrying on fresh connections, which needs connections spread across
workers: Windows serves HTTP from one worker and Bun does not spread them, so CI's Windows and Bun
legs never reached the scan owner. The same skip as sealed-redeclare-tracked-update; the negative-TTL,
declaring-thread and threads: 0 suites still run there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: proposal-kriszyp-bundle-harper-table-expiration-config-5df9b5
…ed owner

On an isolated application's thread, ttlConfiguredByApplication meant both
"application code set this TTL" and "this thread's schema claimed the scan", so a
@table with no expiration cleared a TTL that sourcedFrom options had set (QA-5424).
Split them: ttlOwnedByThread keeps the scan ownership latch unchanged, and
applicationTtl records which part of the TTL in effect application code set. A
declaration with no TTL keeps that part, isolated or not, and clears what a schema
authored: a schema TTL that replaced an application's, and the eviction and
scanInterval a number-form call left in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-4250-f1241-ttl-flag-split
…ps an application TTL

The persistence suite created its tables in the configured test database, which
per-file teardown keeps, so every later file reopened them. The QA-5424 case uses a
500 ms TTL so a slow commit cannot evict the row before the presence check. The
schema reference now says a schema that sets a TTL to 0 leaves an application's in
place, as one that omits it does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-4250-f1241-ttl-flag-split
…expiresAt warning

The promotion test's row expired 100 ms after the put while its declaring class still
scanned, so a slow commit evicted it early; it now expires after 1 s and the test
waits for that. isDailyCadence rejected a daily timer due within a minute of the next
slot. The per-record expiresAt warning no longer says the scan does not survive a
restart when a cleared TTL's expiryScan mark keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-4250-f1241-ttl-flag-split
…ly slot's milliseconds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dispatch-Task: harper-4250-f1241-ttl-flag-split
@kriszyp
kriszyp marked this pull request as ready for review November 11, 2026 14:34
@kriszyp
kriszyp merged commit e7e9564 into main Nov 11, 2026
35 of 83 checks passed
@kriszyp
kriszyp deleted the fix/table-ttl-config-clear-validate-arm branch November 11, 2026 14:34
kriszyp added a commit that referenced this pull request Nov 12, 2026
…hanges (#4265)

* Hold a design-docs ceilings change only to the files whose entry it changes

Editing build-tools/design-docs-ceilings.json held every ceilinged file to
its ceiling, so retiring one file's ceiling after a prune failed on any other
file a merge race had left over its own. With security/DESIGN.md 30 bytes over
since #4110, a PR pruning resources/DESIGN.md (3 bytes over since #4250 and
#4120 merged against different bases) would also have had to cut
security/DESIGN.md to 60% of its ceiling. Now a ceilings change holds a file
only when its entry was added, removed, raised or lowered; raised or new
ceilings, dropped entries for files still over budget, and the 60% floor for
changed files are checked as before.

Dispatch-Task: main-red-kriszyp_harper_e7e9564dc_5684deb1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say in check-design-docs' header that a ceilings edit holds the files whose entry changed

Dispatch-Task: main-red-kriszyp_harper_e7e9564dc_5684deb1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reword check-design-docs' --base usage so 'to their budget' binds to 'hold'

Dispatch-Task: main-red-kriszyp_harper_e7e9564dc_5684deb1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Nov 12, 2026
…ing (#4290)

* Bring resources/DESIGN.md under its 40 KiB budget and retire its ceiling

#4250 left resources/DESIGN.md at 45,886 bytes, 3 over its 45,883-byte
ceiling. Under --base, check-design-docs.mjs holds any change to an
over-ceiling file to 60% of that ceiling (27,529 bytes), which is under the
40,960-byte budget, so --update-ceilings drops the file's entry.

Rewrite all 73 notes tersely down to 27,497 bytes. Every heading (and so
every anchor the root index links), every issue reference and every named
test is kept; bold invariants, gaps and residual risks stay, and mechanism
detail the named tests pin is dropped per the root DESIGN.md rule.

Dispatch-Task: harper-design-doc-ceiling-resources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Correct statements the resources/DESIGN.md trim overgeneralized

Restore the qualifications the review found missing: collection positions
and the single-record resume exception, the includeSuperseded startTime
exception, expiry hiding only without a source get, the cached getEntry
options literal, TableResourceClass, syncWal-conditional floor sync,
source- versus request-level noCacheStore, the static-mode array PUT gap,
residency stubs, LMDB close completion and coercion's pattern-text
exception. Fund them by writing harper refs as #N and tighter wording;
the file is 27,500 bytes.

Dispatch-Task: harper-design-doc-ceiling-resources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Qualify single-record resume and caching-table expiry, and reattach three test pointers

Dispatch-Task: harper-design-doc-ceiling-resources
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant