You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Declarative residency policy so threads without application code shard correctly #980
Follow-up to #975 (dedicated replication worker pool), under #435. Proposal, not a committed design: the questions at the end decide the shape.
Problem
Residency rules are JavaScript functions an application installs per thread: Table.setResidency(record => …) and Table.setResidencyById(id => …) (core resources/Table.ts, the setResidency/setResidencyById statics). A thread that does not run the application's code has neither function. Two kinds of thread now run without application code:
Replication pool workers (replication.threads > 0, Run replication on a dedicated worker pool (replication.threads) #975). The sender skips residency invalidations to non-resident peers only when table.getResidencyById is installed (replication/replicationConnection.ts, the table.getResidencyById test in the audit-send loop). On a pool worker it never is. An application that shards by id with setResidencyById therefore sends invalidation entries with indexed-field stubs (residency partials) to every non-resident peer, which stores them, where today it sends nothing. Run replication on a dedicated worker pool (replication.threads) #975 accepts this for 5.4 and documents "keep replication.threads: 0 if you use setResidencyById".
The main thread (likely already today, unverified). Application plugins run handleApplication only on workers (components/componentLoader.ts, resources.isWorker && extensionModule.handleApplication), and operations-API writes execute on the main thread. If that holds, an upsert through the operations API computes no residency at all. verify: with an app that calls setResidency, then an upsert operation, then inspect the record's residencyId.
The root cause is that residency is a placement policy that every node and every thread must agree on, but it is defined as thread-local code. Residency lists themselves are already storage-backed: the writer stores a residencyId (dbisresidency_by_id) and the list travels with the audit record, so receivers never re-evaluate it. Only the policy that produces it is thread-local.
Proposal
Make the common residency policies declarative table metadata, persisted with the table the way replicate already is (core resources/databases.ts, primaryAttribute.replicate), so every thread that opens the table materializes the same getResidency/getResidencyById without loading application code.
byId: { shards: N } is hash sharding: shard = hash(encodedPrimaryKey) % N, mapped to nodes by the existing replication.shard membership (server.shards, replication/knownNodes.ts). It is installed as getResidencyById, so it drives all three places that function drives today: the sender's skip, full omission on write (no residency partial), and the read fast path that routes a miss to a resident node.
byAttribute maps an attribute value to a shard and is installed as getResidency. Moving a record between shards uses the existing relocation path (_recordRelocate).
Shard membership stays in config (replication.shard per node, replication.databases[].sharded), as it is now. Only the policy moves into table metadata, because the policy must be identical on every node while membership is per node.
One source per table. A table with declarative residency refuses setResidency/setResidencyById from application code (a clear error at the call), so the function and the metadata cannot diverge between threads. A table without it keeps the function API unchanged.
The hash is part of the storage format. It must be fixed and documented (for example FNV-1a 32-bit over the ordered-binary key encoding), never "whatever the runtime's hash is", since changing it relocates every record.
Alternatives
Option
Why not (or when)
Policy in root config (replication.databases[].residency.<table>)
Config files are per node and can drift; two nodes with different policies place the same record differently, and nothing detects it. Table metadata replicates with the schema. Config could still be a deploy-time source that writes the metadata.
A residency module path loaded on every thread (replication.residencyModule: ./residency.js)
Keeps arbitrary code, so the pool would load application code again, which #975 exists to avoid, and the main thread would need a module loader it does not have today.
Do less: persist only a residencyById mode bit when setResidencyById is called
Fixes the sender's skip on the pool (it only tests for the function's presence), with almost no code. It does not fix omission-on-write or the read fast path on threads without the function, nor the main-thread gap. A reasonable stopgap if the full proposal waits.
Where the code changes
core resources/Table.ts: build getResidency/getResidencyById from table metadata at table open; refuse the function API on a declarative table.
core resources/databases.ts + schema directive parsing: persist and replicate the residency metadata (same path as replicate).
harper-pro replication/replicationConnection.ts: no change once getResidencyById exists on every thread.
Open questions
Schema directive only, or also a set_table_residency operation for tables defined without a schema?
Changing shards on an existing table: refuse, or define it as a relocation migration?
Is the main-thread gap real today (see verify: above)? If so it is a bug on its own, independent of the pool.
Size: real work (core schema + metadata + Table, small harper-pro follow-through). Not blocking #975, which ships with the documented limitation.
🤖 Generated by Claude (Opus 5.5); posted via @kriszyp.
Same root cause, second instance, found while reviewing #975: an @indexed computed attribute whose resolver the application assigns with setComputedAttribute exists only on threads that ran the application.
Verified on 2026-10-05 with a 2-node fixture (integrationTests/cluster/fixture-computed-index in the #975 PR):
write path
setComputedAttribute index
@computed(from: "…") index
REST PUT on an HTTP worker
indexed
indexed
operations-API upsert (main thread), no replication pool
not indexed
indexed
replicated write applied on a replication.threads worker
So the main-thread gap exists today regardless of the pool. It answers the verify: above for computed attributes; residency on the main thread is still unverified. The declarative direction this issue proposes for residency covers it too. @computed(from: "…") is already the declarative form, persisted with the schema and resolved on every thread. A setComputedAttribute-only index could become a schema-time error, or a warning naming the threads that cannot maintain it. Core now exposes Table.unresolvedComputedIndexes() for exactly that check.
🤖 Generated by Claude (Opus 5.5); posted via @kriszyp.
Follow-up to #975 (dedicated replication worker pool), under #435. Proposal, not a committed design: the questions at the end decide the shape.
Problem
Residency rules are JavaScript functions an application installs per thread:
Table.setResidency(record => …)andTable.setResidencyById(id => …)(coreresources/Table.ts, thesetResidency/setResidencyByIdstatics). A thread that does not run the application's code has neither function. Two kinds of thread now run without application code:replication.threads > 0, Run replication on a dedicated worker pool (replication.threads) #975). The sender skips residency invalidations to non-resident peers only whentable.getResidencyByIdis installed (replication/replicationConnection.ts, thetable.getResidencyByIdtest in the audit-send loop). On a pool worker it never is. An application that shards by id withsetResidencyByIdtherefore sends invalidation entries with indexed-field stubs (residency partials) to every non-resident peer, which stores them, where today it sends nothing. Run replication on a dedicated worker pool (replication.threads) #975 accepts this for 5.4 and documents "keepreplication.threads: 0if you usesetResidencyById".handleApplicationonly on workers (components/componentLoader.ts,resources.isWorker && extensionModule.handleApplication), and operations-API writes execute on the main thread. If that holds, anupsertthrough the operations API computes no residency at all.verify:with an app that callssetResidency, then anupsertoperation, then inspect the record'sresidencyId.The root cause is that residency is a placement policy that every node and every thread must agree on, but it is defined as thread-local code. Residency lists themselves are already storage-backed: the writer stores a
residencyId(dbisresidency_by_id) and the list travels with the audit record, so receivers never re-evaluate it. Only the policy that produces it is thread-local.Proposal
Make the common residency policies declarative table metadata, persisted with the table the way
replicatealready is (coreresources/databases.ts,primaryAttribute.replicate), so every thread that opens the table materializes the samegetResidency/getResidencyByIdwithout loading application code.byId: { shards: N }is hash sharding:shard = hash(encodedPrimaryKey) % N, mapped to nodes by the existingreplication.shardmembership (server.shards,replication/knownNodes.ts). It is installed asgetResidencyById, so it drives all three places that function drives today: the sender's skip, full omission on write (no residency partial), and the read fast path that routes a miss to a resident node.byAttributemaps an attribute value to a shard and is installed asgetResidency. Moving a record between shards uses the existing relocation path (_recordRelocate).replication.shardper node,replication.databases[].sharded), as it is now. Only the policy moves into table metadata, because the policy must be identical on every node while membership is per node.setResidency/setResidencyByIdfrom application code (a clear error at the call), so the function and the metadata cannot diverge between threads. A table without it keeps the function API unchanged.Alternatives
replication.databases[].residency.<table>)replication.residencyModule: ./residency.js)residencyByIdmode bit whensetResidencyByIdis calledWhere the code changes
resources/Table.ts: buildgetResidency/getResidencyByIdfrom table metadata at table open; refuse the function API on a declarative table.resources/databases.ts+ schema directive parsing: persist and replicate the residency metadata (same path asreplicate).replication/replicationConnection.ts: no change oncegetResidencyByIdexists on every thread.Open questions
set_table_residencyoperation for tables defined without a schema?shardson an existing table: refuse, or define it as a relocation migration?verify:above)? If so it is a bug on its own, independent of the pool.Size: real work (core schema + metadata + Table, small harper-pro follow-through). Not blocking #975, which ships with the documented limitation.
🤖 Generated by Claude (Opus 5.5); posted via @kriszyp.