Repository navigation
Clear a removed table TTL everywhere, reject invalid TTLs where they are declared, and scan catalog-loaded tables without a write - #4250
Merged
Conversation
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
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
marked this pull request as ready for review
November 11, 2026 14:34
This was referenced Nov 11, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A table's saved
expirationandevictionhad four gaps:@table, or setting0, never cleared it. Records kept expiring, after restarts too.create_tableignoredeviction.threads: 0, catalog-loaded tables were not scanned until a write.🤔 Clearing is a behavior change
An operator who relied on a removed
expirationstaying in effect must declare it again. Records already written keep the expiry they were given.💡 Solution
@tableanddefineTableclear what they omit.table()callers such asadd_attributekeep it.sourcedFromoptions survives a schema without one, in isolated applications too.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
0counts as declaring no TTLexpiration: 0leaves an application-set TTL in place, as omitting it does;declareTablecannot 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
expiryScanon 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,compactOnStartandmigrateOnStartfirst, 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
defineTableand applicationtable()calls.Treat only an explicit
0as clear: removing a value from a schema would still do nothing.Arm
threads: 0scans right at promotion: a stale saved TTL would start evicting while the schema that removes it is still loading.🔧 Changes
@table/defineTablewithoutexpirationexpiration: 0ornulltable({ expiration })withouteviction(add_attribute, cluster events)expiration: -1,'soon',true-1failed naming no table;'soon'andtruewere acceptedcreate_tablewithevictionFiles
resources/Table.ts:ttlSecondsOfvalidates andsavedTtlSecondsreads saved values;setTTLExpirationvalidates every value first, leaves the part of the TTL application code set, asapplicationTtlrecords it, and holds the one cadence rule; a clear of a scheduled TTL setsretainExpiryScan(declared from the catalog'sexpiryScan);_adoptCatalogExpirationadopts cleared and eviction-only rows and the reload'sexpiryScan;_armCleanupScan;makeTablevalidates any loaded TTL that is not absent or zero, so a peer's bad value fails its create;resumeMaintenancekeeps the retained scan;scheduleCleanupdrops its zero-interval exit, which nothing reaches now.resources/databases.ts:ttlChangedandloadedTtlSeconds, whichinitStoresuses to load an invalid saved value as absent with a warning;initStorespassesexpiryScanto a new class and to a reloaded one; the declaring thread applies a clear;persistTableSettingsmarks the rowexpiryScan;armTableCleanupScans;TableDefinitionallowsnull.server/threads/socketRouter.tscallsarmTableCleanupScansafterstartServers()underthreads: 0.dataLayer/harperBridge/ResourceBridge.ts:create_tableforwardseviction.dataLayer/schemaDescribe.ts:describe_tableshows noexpirationonce no TTL is in effect; an eviction-only table still shows0s.schema.md,operations.md, the 5.4 release note. Design notes:resources/DESIGN.md,dataLayer/DESIGN.md(drops the two TTL limits this fixes),resources/MAP.md.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?
declareTablechecks each value withttlSecondsOfbefore anything is opened or saved, so a bad value leaves no table and no catalog row behind.@tablepassesnullfor what it omits, and so doesdefineTable.persistTableSettingsdeletes a cleared field. Atable()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?
When the cleanup scan arms
When does each thread start reclaiming expired rows?
From load to scan
scanInterval, else(expiration + eviction) / 4, else daily.expiryScan.threads: 0, after components load,armTableCleanupScansarms each loaded table that has a TTL orexpiryScan.✅ Verification
sourcedFromTTL): four new unit cases fail at 695e719 and pass here, on RocksDB and LMDB.main; this head's change is unit-covered, and CI re-runs it.Test details
@tablewithout a TTL kept zeroing a TTL fromsourcedFromoptions (QA-5424). Four new unit cases cover it: the isolated table factory withsourcedFromfirst, both owner kinds, a schema TTL that replaced an application's, and a number-form call over a schema TTL. All four fail at 695e719 and pass here, on RocksDB and LMDB.table-expiration-config.test.ts(threads: 0suite): RocksDB and LMDB, four workers andthreads: 0, with restarts. 12/12 passed at 695e719; it was not re-run locally on this head (no loopback aliases on the worker), so CI runs it. On anorigin/mainbuild every check fails except the four-worker runtime-table reclaim, which Serve id and id-plus-indexed-attribute selects from the index entry instead of reading every record #3938 already fixed.ttlCleanupInterval.test.jsandtableExpirationPersistence.test.js, green on RocksDB and LMDB; the 13 written first all fail onmain.createTableCatalogOrder.test.jsnow fails insidemakeTablethrough a peer definition, since a local one is rejected earlier.runtime-table-ttl-eviction,record-caching-ttl-eviction,apiTests/ttl, the new test): 20 pass, 0 fail.Test fixtures
Its fixture's
TtlControlpins declarations to one worker so the scan owner learns TTLs only from the catalog;schema.graphqlandconfig.yamldeclare 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:resourcesrun6669 passing. The same 101-test
ConflictRetryTable/cascade andoriginClosedFloorclock failure occur on amainbuild. Three replica-pressure timing assertions failed in the full run; interleaved,replicaStorageReclamation.test.jspassed 5/5 on both branch andmain.Performance: no record read or write path changes. Validation runs per declaration;
socketRouter.tsadds one pass over loaded tables atthreads: 0startup; 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 independentComplexity: complicated
Deferred findings
b8406dd465c94218minor 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 #425361d6fd7c994922b5minor reliability —resources/Table.ts— peer definition with legacy invalid TTL now fails table create; catalog load tolerates same value — NOT FILEDac1c5dc3bd820ff9nit 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 #42429ee5a87fa17da5ddnit maintainability —server/threads/socketRouter.ts— suggest comment on why multi-worker path needs no global arm — NOT FILED27445315efa84340nit maintainability —integrationTests/database/table-expiration-config.test.ts— added comments narrate setup, identifiers, arithmetic — NOT FILEDOrigin — 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-1Review-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