@voltro/runtime 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1602,6 +1602,36 @@ declare const CircuitOpen_base: Schema.TaggedErrorClass<CircuitOpen, "CircuitOpe
1602
1602
 
1603
1603
  export declare type CircuitState = 'closed' | 'open' | 'half-open';
1604
1604
 
1605
+ /** How many bucket widths of predecessors a won claim leaves behind. */
1606
+ export declare const CLAIM_RETENTION_BUCKETS = 64;
1607
+
1608
+ /**
1609
+ * How long a claim row outlives its own bucket before the next winner deletes
1610
+ * it — and the reasoning is the whole safety argument, so read it before
1611
+ * shrinking the number.
1612
+ *
1613
+ * A claim answers ONE question ("has this bucket been taken"), and it is only
1614
+ * ever asked while some replica still has a timer pending for that bucket.
1615
+ * Delete it too early and a straggler re-claims a bucket that already fired,
1616
+ * which is a DOUBLE FIRE — the failure this table exists to prevent. So the
1617
+ * grace has to cover the longest a replica can plausibly be late.
1618
+ *
1619
+ * The two callers are late in different ways, which is why the width is a
1620
+ * parameter rather than a constant:
1621
+ *
1622
+ * - `scheduleCoordinated` computes its bucket from `Date.now()` AT TICK TIME.
1623
+ * A tick stalled by ten minutes therefore claims the CURRENT bucket, never
1624
+ * the one it was armed for — a stale bucket is unreachable by construction,
1625
+ * and 64 widths is generous past the point of paranoia.
1626
+ * - a CRON firing carries its own scheduled instant, so a stalled firing DOES
1627
+ * re-present an old bucket. It passes no width, so it gets 64 × 60 s ≈ 68
1628
+ * minutes of grace, and a cron fires at most once a minute, so that costs
1629
+ * ~68 rows.
1630
+ *
1631
+ * The floor keeps a sub-second task from computing a grace measured in seconds.
1632
+ */
1633
+ export declare const claimGraceMs: (bucketWidthMs: number | undefined) => number;
1634
+
1605
1635
  /**
1606
1636
  * Decide whether a candidate matches a maintainable shape. Conservative by
1607
1637
  * design — anything not provably maintainable is rejected (→ full recompute),
@@ -1852,6 +1882,8 @@ export declare interface ConnectionTokens {
1852
1882
  readonly scopes: ReadonlyArray<string>;
1853
1883
  }
1854
1884
 
1885
+ export declare type CoordinatedEffect = () => void | CoordinatedTickOutcome | Promise<void | CoordinatedTickOutcome>;
1886
+
1855
1887
  export declare interface CoordinatedScheduleDeps {
1856
1888
  /** The exactly-once gate. `singleCoordinator` for one-process
1857
1889
  * deployments; `makeAdvisoryLockCoordinator(store, replicaId)` for
@@ -1861,6 +1893,31 @@ export declare interface CoordinatedScheduleDeps {
1861
1893
  readonly log?: SchedulerLogger;
1862
1894
  /** Stable id of this replica — recorded on the claim row it wins. */
1863
1895
  readonly replicaId: string;
1896
+ /**
1897
+ * Ceiling for the idle backoff. Defaults to `VOLTRO_POLL_CEILING_MS` (see
1898
+ * {@link readPollCeilingMs}), else {@link DEFAULT_MAX_IDLE_INTERVAL_MS}.
1899
+ * Set it EQUAL to the base interval to
1900
+ * opt a task out of backing off entirely — which is the right call only for a
1901
+ * task whose work cannot announce itself.
1902
+ */
1903
+ readonly maxIdleIntervalMs?: number;
1904
+ /**
1905
+ * **Stop ticking entirely** on an idle tick with no known deadline, and come
1906
+ * back only on `wake()`. Default `false`.
1907
+ *
1908
+ * This is the difference between "a poller that got cheaper" and "no poller".
1909
+ * A deployment that never uses the queue this task drains pays ONE tick at
1910
+ * boot — which is not optional, it is what finds work a previous process left
1911
+ * behind — and then nothing at all.
1912
+ *
1913
+ * **Only pass `true` when an arrival is GUARANTEED to produce a `wake()`.**
1914
+ * That is a claim about the deployment, not about the task: with Postgres
1915
+ * LISTEN/NOTIFY or a broadcast transport every replica sees every enqueue, so
1916
+ * it holds. Without either, a REMOTE replica's enqueue produces no local
1917
+ * event, and a disarmed task would sleep through it forever. The backoff
1918
+ * ceiling exists for exactly that case and is the correct choice there.
1919
+ */
1920
+ readonly disarmWhenIdle?: boolean;
1864
1921
  }
1865
1922
 
1866
1923
  export declare interface CoordinatedScheduleHandle {
@@ -1868,6 +1925,52 @@ export declare interface CoordinatedScheduleHandle {
1868
1925
  readonly stop: () => void;
1869
1926
  /** The task name (for logging / dedup diagnostics). */
1870
1927
  readonly name: string;
1928
+ /**
1929
+ * Run a tick now, because something arrived.
1930
+ *
1931
+ * Coalesced to at most one extra tick per BASE interval: a queue drain writes
1932
+ * to the very table whose change events trigger this, so an uncoalesced wake
1933
+ * is a loop that feeds itself. Safe to call from a change handler, from any
1934
+ * replica, at any rate.
1935
+ */
1936
+ readonly wake: () => void;
1937
+ /** The delay the next tick is currently armed for. Exposed for tests and the
1938
+ * inspect surface — a task sitting at the idle ceiling and one hammering the
1939
+ * base interval look identical from outside otherwise. */
1940
+ readonly currentIntervalMs: () => number;
1941
+ /** `false` once the task has stopped ticking and is waiting for `wake()`
1942
+ * (see `disarmWhenIdle`). A disarmed task and a stopped one are the same
1943
+ * thing from outside otherwise, and only one of them comes back. */
1944
+ readonly isArmed: () => boolean;
1945
+ }
1946
+
1947
+ /** Per-task overrides a caller may pass alongside the effect. */
1948
+ export declare interface CoordinatedTaskOptions {
1949
+ /** See {@link CoordinatedScheduleDeps.disarmWhenIdle}. Per TASK rather than
1950
+ * per process, because whether an arrival wakes you is a property of the
1951
+ * queue you drain — one plugin may have a change channel on its table and
1952
+ * another none. */
1953
+ readonly disarmWhenIdle?: boolean;
1954
+ }
1955
+
1956
+ /** What a tick learned. Returning nothing means "assume there was work" —
1957
+ * the conservative reading, so a task that does not report cannot be backed
1958
+ * off into missing something. */
1959
+ export declare interface CoordinatedTickOutcome {
1960
+ /** `true` when the tick found nothing to do. Only an idle tick backs off. */
1961
+ readonly idle: boolean;
1962
+ /**
1963
+ * Milliseconds until the earliest thing this task already knows is coming —
1964
+ * a debounce window closing, a lease expiring. Caps the backoff, so a task
1965
+ * that is idle RIGHT NOW but has a deadline in 400 ms is armed for 400 ms
1966
+ * rather than for 30 s.
1967
+ *
1968
+ * This is the part a fixed interval cannot express and the part that makes
1969
+ * the backoff safe: without it, backing off is a bet that nothing time-based
1970
+ * is pending, and deferring controls are exactly the case where that bet is
1971
+ * wrong.
1972
+ */
1973
+ readonly nextDueInMs?: number;
1871
1974
  }
1872
1975
 
1873
1976
  export declare type CoordinationOutcome = 'single' | 'wonLock' | 'lostLock' | 'external' | 'cluster';
@@ -1880,7 +1983,15 @@ export declare type CoordinationOutcome = 'single' | 'wonLock' | 'lostLock' | 'e
1880
1983
  */
1881
1984
  export declare interface Coordinator {
1882
1985
  readonly kind: 'single' | 'advisoryLock' | 'cluster';
1883
- tryClaim(scheduleName: string, scheduledAt: Date): Promise<boolean>;
1986
+ /**
1987
+ * @param bucketWidthMs How far apart two consecutive buckets of THIS caller
1988
+ * are. Optional, and it is not used to decide the claim — it sizes how long
1989
+ * a won claim keeps its own predecessors around (see `claimGraceMs`). A cron
1990
+ * omits it and gets the conservative default; `scheduleCoordinated` passes
1991
+ * its interval, which is how a 250 ms task stops leaving a day of rows
1992
+ * behind.
1993
+ */
1994
+ tryClaim(scheduleName: string, scheduledAt: Date, bucketWidthMs?: number): Promise<boolean>;
1884
1995
  }
1885
1996
 
1886
1997
  /**
@@ -2300,6 +2411,14 @@ export declare const dataStoreKvStore: (store: DataStore) => KvStoreShape;
2300
2411
  * cipher is registered (a genuine ciphertext with a wrong key throws GCM). */
2301
2412
  export declare const decryptField: (value: string) => string;
2302
2413
 
2414
+ /** Assumed distance between buckets when a caller passes none — the cron
2415
+ * engine's finest useful cadence. */
2416
+ export declare const DEFAULT_CLAIM_BUCKET_MS = 60000;
2417
+
2418
+ /** Ceiling the idle backoff climbs to. Deliberately short enough to be a
2419
+ * FLOOR under a missed wake rather than a substitute for one. */
2420
+ export declare const DEFAULT_MAX_IDLE_INTERVAL_MS = 30000;
2421
+
2303
2422
  /** Default `POST /rpc` body cap: 8 MiB. Generous for any JSON rpc envelope,
2304
2423
  * small enough to stop a pathological body being buffered into memory. */
2305
2424
  export declare const DEFAULT_MAX_RPC_BODY_BYTES: number;
@@ -4516,9 +4635,21 @@ export declare const makeActionRunner: (deps: ActionRunnerDeps) => (action: Muta
4516
4635
  * session-level `pg_advisory_lock` (which is tied to a connection
4517
4636
  * that a pool may hand to another query before we unlock).
4518
4637
  *
4519
- * The minute-bucket keying makes claims self-expiring: a crashed
4520
- * winner doesn't block the next firing (= next minute = new key). Old
4521
- * claim rows are pruned lazily (see `pruneClaimsOlderThan`).
4638
+ * The bucket keying makes claims self-expiring as a DECISION: a crashed
4639
+ * winner doesn't block the next firing (= next bucket = new key). It did
4640
+ * not make them self-expiring as ROWS, and that distinction cost a consumer
4641
+ * their whole deployment — 86 214 rows / 33 MB over two days, read in full
4642
+ * on every claim check, ten of a fifteen-slot pooler pinned on the scan, an
4643
+ * SSR render behind them at 300 490 ms, and a `rollout restart` that could
4644
+ * not complete because the surge pod could not get a connection.
4645
+ *
4646
+ * **A won claim now deletes its own predecessors** (`claimGraceMs` above),
4647
+ * which is what bounds the table rather than merely slowing its growth. The
4648
+ * retention sweep both boot paths register (`wireRetentionSweep`,
4649
+ * `VOLTRO_SCHEDULE_CLAIMS_TTL_HOURS`) STAYS as the backstop, and it is not
4650
+ * redundant: this prune is per SCHEDULE NAME and only runs when that name wins
4651
+ * again, so the rows of a schedule that was renamed or deleted have nothing
4652
+ * left to clean them up.
4522
4653
  *
4523
4654
  * `scheduledAt` is the DETERMINISTIC cron instant (not `Date.now()`),
4524
4655
  * so every replica computes the SAME bucket regardless of clock skew
@@ -4616,7 +4747,7 @@ export declare const makeConnectionsFacade: (deps: ConnectionsFacadeDeps) => Con
4616
4747
  * handle is tracked by the caller (the CLI) so `onDeactivate` can stop
4617
4748
  * every task a plugin armed.
4618
4749
  */
4619
- export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => ((name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle);
4750
+ export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => PluginScheduleCoordinated;
4620
4751
 
4621
4752
  /**
4622
4753
  * Build a request-scoped loader. One instance per AppContext — see the module
@@ -5121,6 +5252,18 @@ export declare interface MutationStore extends DataStore {
5121
5252
  hardDelete(table: string, primaryKey: string): Promise<boolean>;
5122
5253
  }
5123
5254
 
5255
+ /**
5256
+ * The backoff curve, extracted so it can be asserted directly — a schedule
5257
+ * that backs off wrongly is otherwise only visible as a latency an integration
5258
+ * test does not measure.
5259
+ *
5260
+ * Doubling rather than jumping to the ceiling: a queue that just went quiet is
5261
+ * the likeliest one to receive something next, and doubling keeps the first few
5262
+ * idle ticks cheap in latency while still reaching the ceiling in five steps
5263
+ * from 1 s.
5264
+ */
5265
+ export declare const nextDelay: (outcome: void | CoordinatedTickOutcome, baseMs: number, currentMs: number, maxIdleMs: number, disarmWhenIdle?: boolean) => number | "disarm";
5266
+
5124
5267
  /** Next firing strictly after `after` (default: now). */
5125
5268
  export declare const nextFiring: (def: ScheduleDefinition, after?: Date) => Date;
5126
5269
 
@@ -5356,6 +5499,18 @@ export declare interface OrchestratorTickDeps {
5356
5499
  readonly log?: WakeOrchestratorLogger;
5357
5500
  }
5358
5501
 
5502
+ /**
5503
+ * Map an `import('@effect/opentelemetry')` rejection to what the reader needs.
5504
+ *
5505
+ * Exported so the branch is testable without uninstalling the package. Only a
5506
+ * module-NOT-FOUND becomes the install instruction: anything else is a real
5507
+ * load failure inside a package that IS present, and renaming that to "not
5508
+ * installed" sends the reader to reinstall something already there. A catch-all
5509
+ * that relabels every failure is how a diagnosis gets buried — the same reason
5510
+ * the serve bundle marks its deliberate refusals instead of swallowing throws.
5511
+ */
5512
+ export declare const otelImportFailure: (cause: unknown) => Error;
5513
+
5359
5514
  /**
5360
5515
  * Retention, part 1 of 2 — the PER-ENTRY cap.
5361
5516
  *
@@ -5544,8 +5699,8 @@ export declare interface PluginRefStore {
5544
5699
  delete: (table: string, id: string) => Promise<unknown>;
5545
5700
  }
5546
5701
 
5547
- /** The 3-arg signature a plugin sees on its bind-ctx. */
5548
- export declare type PluginScheduleCoordinated = (name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle;
5702
+ /** The signature a plugin sees on its bind-ctx. */
5703
+ export declare type PluginScheduleCoordinated = (name: string, intervalMs: number, effect: CoordinatedEffect, options?: CoordinatedTaskOptions) => CoordinatedScheduleHandle;
5549
5704
 
5550
5705
  export declare const powerOfTwoSelector: (options?: P2COptions) => ReplicaSelector;
5551
5706
 
@@ -5879,6 +6034,29 @@ export declare type ReactiveReturn<D extends ExecutorDescriptor> = D extends {
5879
6034
  readonly descriptor: QueryDescriptor;
5880
6035
  } : never;
5881
6036
 
6037
+ /**
6038
+ * The ceiling, tunable per deployment via `VOLTRO_POLL_CEILING_MS`.
6039
+ *
6040
+ * This is the ONE number worth exposing, and the reason is what the ceiling
6041
+ * means: it is how long an arrival can wait when nothing woke the task. With
6042
+ * reactivity it is never reached. WITHOUT it — a dialect with no CDC and no
6043
+ * broadcast transport, where a remote replica's enqueue produces no local
6044
+ * event — it is the whole latency budget, and only the operator knows how much
6045
+ * of one they have.
6046
+ *
6047
+ * An env var rather than an `app.config.ts` field on purpose: it is an
6048
+ * operational number, it must be identical under `voltro dev` and `voltro
6049
+ * serve`, and a second source for one value is how the two boot paths come to
6050
+ * disagree. Read here, once, so neither path can supply its own.
6051
+ *
6052
+ * An unparseable or non-positive value is ignored rather than honoured — a
6053
+ * ceiling of 0 would turn every idle task into a spin, which is the exact
6054
+ * pathology the backoff exists to remove.
6055
+ */
6056
+ export declare const readPollCeilingMs: (env?: {
6057
+ readonly VOLTRO_POLL_CEILING_MS?: string;
6058
+ }) => number;
6059
+
5882
6060
  /** `admin:full` (mirrors `@voltro/protocol`'s ADMIN_SCOPE) bypasses ReBAC. */
5883
6061
  export declare const REBAC_ADMIN_SCOPE = "admin:full";
5884
6062
 
@@ -7080,29 +7258,27 @@ export declare interface ScheduleContext {
7080
7258
  }
7081
7259
 
7082
7260
  /**
7083
- * Run `effect` every `intervalMs`, but on only ONE replica per tick.
7261
+ * Run `effect` on only ONE replica per tick, on a cadence that follows the
7262
+ * work rather than a fixed clock.
7084
7263
  *
7085
- * On each tick the runner floors the wall clock to an `intervalMs`
7086
- * bucket and asks the coordinator to claim `(name, bucket)`. Only the
7087
- * replica that wins the claim runs the effect; the rest skip that tick.
7088
- * Because the bucket is derived from the shared wall clock (not each
7089
- * replica's tick offset), every replica computes the same bucket within
7090
- * the window and races on the identical claim key — the INSERT-wins
7091
- * arbiter picks one.
7264
+ * Each tick floors the wall clock to an `intervalMs` bucket and asks the
7265
+ * coordinator to claim `(name, bucket)`. Only the replica that wins runs the
7266
+ * effect; the rest skip. Because the bucket comes from the shared wall clock
7267
+ * (not each replica's tick offset), every replica computes the same bucket
7268
+ * within the window and races on the identical claim key.
7092
7269
  *
7093
- * Non-dying: a throw inside `effect` is caught + logged; the next tick is
7094
- * always re-armed. The timer is `unref`'d so it never keeps the process
7095
- * alive on its own (mirrors the bare-`setInterval` behaviour it replaces).
7270
+ * Non-dying: a throw inside `effect` is caught + logged and the next tick is
7271
+ * always re-armed. The timer is `unref`'d so it never keeps the process alive.
7096
7272
  *
7097
7273
  * @param name Stable task name, namespaced by the caller (a plugin
7098
7274
  * passes e.g. `presence.sweep`). Used as the claim key
7099
7275
  * prefix + in logs.
7100
- * @param intervalMs Period between ticks. Also the claim bucket width
7101
- * a crashed winner doesn't block the next tick (= next
7102
- * bucket = new claim key).
7103
- * @param effect The work to run when this replica wins the tick.
7276
+ * @param intervalMs The BASE period the fastest this task ticks, the claim
7277
+ * bucket width, and the wake coalescing window.
7278
+ * @param effect The work. Return a {@link CoordinatedTickOutcome} to let
7279
+ * the runner back off when there is nothing to do.
7104
7280
  */
7105
- export declare const scheduleCoordinated: (name: string, intervalMs: number, effect: () => void | Promise<void>, deps: CoordinatedScheduleDeps) => CoordinatedScheduleHandle;
7281
+ export declare const scheduleCoordinated: (name: string, intervalMs: number, effect: CoordinatedEffect, deps: CoordinatedScheduleDeps) => CoordinatedScheduleHandle;
7106
7282
 
7107
7283
  export declare interface ScheduleDefinition {
7108
7284
  readonly name: string;