Skip to content

Declarative residency policy so threads without application code shard correctly #980

Description

@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 => …) 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 (dbis residency_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.

type Order @table @residency(byId: { shards: 8 }) {
	id: ID @primaryKey
	region: String
}

type Customer @table @residency(byAttribute: "region", shards: { us: 1, eu: 2 }, default: 1) {
	id: ID @primaryKey
	region: String
}
  • 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

  1. Schema directive only, or also a set_table_residency operation for tables defined without a schema?
  2. Changing shards on an existing table: refuse, or define it as a relocation migration?
  3. 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.

Activity

  1. added theissue type on Oct 5, 2026
  2. kriszyp commented on Oct 6, 2026

    @kriszyp
    MemberAuthor

    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 not indexed (now refused, see #975) indexed

    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.

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

    No labels
    No labels

    Fields

    Priority

    P2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions