@rikalabs/akter 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/actor/descriptor.js +3 -0
  3. package/dist/actor/handles.d.ts +1 -1
  4. package/dist/actor/layers.js +2 -0
  5. package/dist/actor/reads.js +2 -1
  6. package/dist/contexts/command.d.ts +2 -0
  7. package/dist/identity/caller.d.ts +4 -4
  8. package/dist/identity/caller.js +11 -2
  9. package/dist/identity/mint.d.ts +1 -1
  10. package/dist/policies/command.d.ts +3 -0
  11. package/dist/policies/command.js +1 -0
  12. package/dist/runtime/connections/owner.js +60 -13
  13. package/dist/runtime/connections/protocol.d.ts +2 -2
  14. package/dist/runtime/connections/streams.js +2 -1
  15. package/dist/runtime/database/bounded.js +17 -2
  16. package/dist/runtime/database/connection.d.ts +3 -2
  17. package/dist/runtime/database/connection.js +9 -4
  18. package/dist/runtime/database/coordination.d.ts +6 -3
  19. package/dist/runtime/database/coordination.js +19 -18
  20. package/dist/runtime/database/migrations.d.ts +4 -0
  21. package/dist/runtime/database/migrations.js +72 -1
  22. package/dist/runtime/entity/mailbox.d.ts +18 -10
  23. package/dist/runtime/entity/mailbox.js +42 -34
  24. package/dist/runtime/entity/register.d.ts +3 -3
  25. package/dist/runtime/entity/register.js +280 -271
  26. package/dist/runtime/events/append.d.ts +3 -3
  27. package/dist/runtime/index.d.ts +4 -1
  28. package/dist/runtime/index.js +2 -1
  29. package/dist/runtime/layer.d.ts +24 -1
  30. package/dist/runtime/layer.js +64 -9
  31. package/dist/runtime/members.d.ts +2 -0
  32. package/dist/runtime/operators/export.d.ts +3 -1
  33. package/dist/runtime/operators/export.js +42 -11
  34. package/dist/runtime/queries.d.ts +1 -1
  35. package/dist/runtime/queries.js +23 -6
  36. package/dist/runtime/registration.js +27 -1
  37. package/dist/runtime/request.d.ts +1 -1
  38. package/dist/runtime/runner.d.ts +18 -1
  39. package/dist/runtime/runner.js +24 -4
  40. package/dist/runtime/storage/cold-storage.d.ts +56 -0
  41. package/dist/runtime/storage/cold-storage.js +138 -0
  42. package/dist/runtime/storage/cold-tier.d.ts +141 -0
  43. package/dist/runtime/storage/cold-tier.js +355 -0
  44. package/dist/runtime/storage/generation.d.ts +14 -0
  45. package/dist/runtime/storage/generation.js +2 -0
  46. package/dist/runtime/storage/placements.js +1 -0
  47. package/dist/runtime/telemetry/metrics.d.ts +4 -0
  48. package/dist/runtime/telemetry/metrics.js +20 -4
  49. package/dist/runtime/telemetry/sampler.js +4 -3
  50. package/dist/runtime/telemetry/spans.d.ts +1 -1
  51. package/dist/runtime/topology/locks.js +8 -6
  52. package/dist/runtime/turn/blobs.d.ts +4 -1
  53. package/dist/runtime/turn/blobs.js +19 -2
  54. package/dist/runtime/turn/execute.d.ts +3 -1
  55. package/dist/runtime/turn/execute.js +272 -20
  56. package/dist/runtime/turn/hooks.d.ts +1 -1
  57. package/dist/runtime/turn/outbox.d.ts +1 -1
  58. package/dist/runtime/turn/receipt.d.ts +1 -1
  59. package/dist/runtime/turn/relay.d.ts +2 -2
  60. package/dist/runtime/turn/relay.js +53 -7
  61. package/dist/testing/actor-test.d.ts +1 -0
  62. package/dist/testing/actor-test.js +4 -1
  63. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.6 (2026-10-11)
4
+
5
+ The framework and CLI release together on the `alpha` and `latest` npm dist-tags. This release adds SQL migrations `0034_cold_tier` and `0035_hot_table_foreign_keys` and an opt-in cold-object format. Rolling-upgrade safety from alpha.5 is **not established**; back up and rehearse a stopped-runner upgrade using the [alpha upgrade notes](https://docs.akter.dev/operations/alpha-upgrades) and [migration procedure](https://docs.akter.dev/operations/02-migrations).
6
+
7
+ - `0034_cold_tier` adds the `cold_ref`, `cold_digest` and `cold_state_version` generation tuple, a cold-version index, `actor_placements.state_version`, constrained `cold` outbox work, the tenant-RLS-protected `actor_cold_garbage` table and its due index, and `durable.actors.cold`. When enabled, state and actor-blob chunks move together into immutable zstd-compressed JSON envelopes with `format: 1` and `codec: 1`, actor identity, routing key, generation, state version, stored key/value pairs and base64 blob chunks; SQL pointers record the compressed bytes' SHA-256. Existing routing-key, warm-state, receipt, event and job payload encodings are unchanged.
8
+ - Adds opt-in Postgres cold storage through `Actors.layer({ coldStorage })`, `policy.coldAfter` and the runtime `ColdStorage` memory, filesystem and S3-compatible adapters. Nothing offloads without a store; PGlite refuses the option. Queries and operator exports read through without restoring; successful commands atomically restore all state and blobs, while declared failures retain the pointer. Command/query object outages are retryable `ActorUnavailable`; failed operator exports return 500, never an empty seed. Retained receipt replay needs no object read. All serving runners need the same store and namespace. Preserve state migration chains needed by cold objects: startup refuses a shortened chain that cannot read them. See [cold-tier verification](https://docs.akter.dev/verification/cold-tier).
9
+ - Enabling cold storage extends the backup boundary to the private object namespace. Configure `backupRetention` plus grace, preserve the deployment id and namespace on restore, stop every runner and maintenance pool, and verify every restored pointer's object, digest and identity before restart. Collectors retain live references and pending cold work through the backup horizon. Filesystem and MinIO evidence does not establish hosted cold-wake latency, provider replication or online PITR; see [backup and restore](https://docs.akter.dev/operations/04-backup-restore).
10
+ - `0035_hot_table_foreign_keys` drops only the `actor_state` and `actor_receipts` foreign keys to `actor_generations`, resolving exact catalog names and refusing unexpected or ambiguous definitions. Both drops use one bounded-lock savepoint/retry boundary; no actor rows are scanned, rewritten or backfilled. Primary/unique keys, tenant RLS, other references and the transactional generation fence remain. Startup applies both migrations before registration; there are no down migrations. See [storage layout](https://docs.akter.dev/architecture/03-storage-layout).
11
+ - **Breaking:** command and reducer tags beginning with `$` are now rejected when compiling an actor because that prefix is reserved for framework maintenance. Rename such declarations before upgrading. `System.source` also gains `cold` for internal cold-state maintenance.
12
+ - **Breaking:** `Runner.socket` now throws the exported `RunnerConfigurationError` synchronously when `entityTerminationTimeout` exceeds `shardLockExpiration - min(shardLockRefreshInterval, shardLockExpiration / 3)`. Previously accepted unsafe configurations are refused, including a 3-second expiration with the omitted 15-second default termination timeout. Set an explicit timeout within the bound, or raise expiration to satisfy it; the ordinary defaults remain valid. See the [alpha upgrade notes](https://docs.akter.dev/operations/alpha-upgrades).
13
+ - Eligible single warm, state-only commands use one guarded Postgres commit flight instead of two. The database still fences generation, event head, creation, cold pointer, receipt identity and expiry; a guard miss discards speculation and uses ordinary admission. Handlers can be evaluated again, and SQL-capable, batched, cold, recovery and new-job/intent/subscription turns retain ordinary admission. Queries always read a database snapshot with the existing replica catch-up/primary fallback rules, never activation memory; `read.version` is undefined. The intermediate cached-query path from [#719](https://github.com/Rika-Labs/akter/pull/719) was removed by [#745](https://github.com/Rika-Labs/akter/pull/745). Protocol-flight evidence is not a production latency or throughput guarantee.
14
+ - Shares activation-worker wiring per actor type and mailbox methods on a prototype, while queues, wake-ups, retry isolation, scoped ownership and actor state remain per activation. The [local retained-heap measurements](https://github.com/Rika-Labs/akter/blob/main/BENCHMARKS.md#resident-heap-follow-up-on-the-mac) do not establish a stable RSS saving or throughput improvement.
15
+ - Fixes `ActorTest.advance` missing an attempt claimed while its lease extension waits for the relay pass lock. The running-attempt snapshot now happens under that lock, preventing a synthetic clock jump from manufacturing lease loss and duplicate same-ID attempts. Production claim, renewal, retry and fencing semantics are unchanged; see [verification](https://docs.akter.dev/verification/relay-clock-jumps). Cold fixtures now wait through actual durable offload retries instead of assuming the first upload succeeded.
16
+ - Adds narrow, opt-in `Database.postgres({ pooling: "transaction", preset: "low-connection" })` for public `Runner.socket` table-lease runners on one primary. It forces `prepare: false`, omits direct-server keepalive startup defaults, disables warm speculation and refuses fleet, cold storage, replicas and separate coordination. Application `LISTEN`, session advisory locks and temporary/session state are outside the mode. Startup migration coordination now uses a transaction-scoped advisory lock including history creation, without a new migration id. Real PgBouncer 1.25.2/Postgres 18.6/Bun 1.4.2 evidence covers bounded startup, rollback, tenant isolation, cancellation, serving and process recovery; Node pooled transport, provider/proxy TLS and general pooler support remain unverified. The four logical clients and proxy backend budget are separate; see [transaction-pooling verification](https://docs.akter.dev/verification/low-connection-postgres#transaction-pooling).
17
+ - The matching CLI adds environment API key schemas and `akter keys create`, `list` and `revoke`; see its [changelog](https://github.com/Rika-Labs/akter/blob/main/apps/cli/CHANGELOG.md). Cloud docs clarify native RDS/Aurora TLS trust, four-session runner and rollout-overlap budgeting, subscription renewal versus Free usage periods, and system-theme/font behavior. These docs do not expand framework provider certification. CI reduces duplicate pipeline setup while continuing to execute tests rather than replay cached results; logs-follow interruption and event-order fixtures synchronize with actual outstanding work.
18
+
19
+ ## 0.1.0-alpha.5 (2026-10-09)
20
+
21
+ The framework and CLI release together on the `alpha` and `latest` npm dist-tags. Stored formats and the default Postgres pool sizes are unchanged from alpha.4.
22
+
23
+ - Adds the supported opt-in `Database.postgres({ url, preset: "low-connection" })` preset: one turn, two off-turn and one query session, for four primary Postgres sessions per runner without read replicas. Explicit pool options override the preset. This uses direct Postgres connections; it does not establish transaction-pooler support.
24
+ - Real-Postgres evidence covers a held single turn session, progressing off-turn work, migration serialization, command deduplication and process recovery. See the [low-connection verification](https://docs.akter.dev/verification/low-connection-postgres) and [deployment guidance](https://docs.akter.dev/guides/deploy).
25
+ - Includes the compute-only Akter Cloud guides and deterministic offline/content conformance timing fixes; the CLI and bundled cloud API contract are unchanged from alpha.4.
26
+
3
27
  ## 0.1.0-alpha.4 (2026-10-09)
4
28
 
5
29
  The framework and CLI release together on the `alpha` and `latest` npm dist-tags. The framework runtime and stored formats are unchanged from alpha.3; the hosted quota and cloud API contracts change during alpha.
@@ -166,6 +166,9 @@ export const compile = ({ name, declared, }) => {
166
166
  const members = [...Object.values(api), ...Object.values(internal)];
167
167
  const commands = members.filter((member) => member.kind === "command");
168
168
  const queries = members.filter((member) => member.kind === "query");
169
+ for (const member of members)
170
+ if ((member.kind === "command" || member.kind === "reducer") && member.tag.startsWith("$"))
171
+ throw new Error(`Command tag ${member.tag} uses the reserved $ prefix`);
169
172
  const connections = members.filter((member) => member.kind === "connection");
170
173
  const streams = members.filter((member) => member.kind === "stream");
171
174
  const reducers = members.filter((member) => member.kind === "reducer");
@@ -20,7 +20,7 @@ export declare const handleOf: (descriptor: Descriptor, id: string, includeInter
20
20
  readonly _tag: "Anonymous";
21
21
  } | {
22
22
  readonly _tag: "System";
23
- readonly source: "actor" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
23
+ readonly source: "actor" | "cold" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
24
24
  readonly ref?: {
25
25
  readonly tenant: string;
26
26
  readonly actor: string;
@@ -13,6 +13,7 @@ const built = (build) => (Effect.isEffect(build) ? build : Effect.succeed(build)
13
13
  const erased = (entries) => entries;
14
14
  const registrationOf = (descriptor, tenant) => ({
15
15
  name: descriptor.name,
16
+ stateVersion: descriptor.state.version,
16
17
  singleton: descriptor.singleton,
17
18
  mintable: descriptor.mintable,
18
19
  watches: descriptor.watches,
@@ -153,6 +154,7 @@ export const queryLayer = ({ descriptor, phases, build, services: capture, }) =>
153
154
  const services = (yield* capture);
154
155
  yield* actors.registerQueries({
155
156
  name: descriptor.name,
157
+ stateVersion: descriptor.state.version,
156
158
  access: descriptor.access,
157
159
  placement: descriptor.placement,
158
160
  timeoutMs: descriptor.policy.executionMs,
@@ -110,6 +110,7 @@ export const queriesOf = ({ descriptor, Read, handlers, services, actors, }) =>
110
110
  maxEntries: descriptor.policy.blobMaxEntries,
111
111
  timeoutMs: descriptor.policy.executionMs,
112
112
  }, false);
113
+ const events = eventsWith({ descriptor, readEvents });
113
114
  const context = {
114
115
  id: request.ref.id,
115
116
  ref: request.ref,
@@ -117,7 +118,7 @@ export const queriesOf = ({ descriptor, Read, handlers, services, actors, }) =>
117
118
  principal: principal(request.caller),
118
119
  state: Object.freeze(state),
119
120
  cursor,
120
- events: eventsWith({ descriptor, readEvents }),
121
+ events,
121
122
  rows: access.rows,
122
123
  group: access.group,
123
124
  blob: blob,
@@ -79,6 +79,8 @@ export interface QueryContext<State, Event extends EventClass = never, Tables ex
79
79
  readonly principal: Option.Option<Principal>;
80
80
  /** The last committed state; never uncommitted writes of a running turn. */
81
81
  readonly state: Readonly<State>;
82
+ /** Always undefined: a query reads the database, not a resident snapshot. */
83
+ readonly version?: string | undefined;
82
84
  /**
83
85
  * The last event committed when `state` was read: resume `events` after it
84
86
  * to follow on from this state without missing or repeating an event.
@@ -30,13 +30,13 @@ export type MintProof = typeof MintProof.Type;
30
30
  * A caller the framework creates for its own deliveries, and the caller of
31
31
  * code that runs in the application's own process. `source` names the
32
32
  * mechanism (the process itself, an actor's intent, a timer, cron, a workflow,
33
- * a job route, or a subscription), `ref` the actor that sent it, and
33
+ * a job route, a subscription, or cold-state maintenance), `ref` the actor that sent it, and
34
34
  * `onBehalfOf` the principal of the turn that caused it. `mint` is set only on
35
35
  * a minted child's creating intent, and proves the child's id was derived by
36
36
  * its parent.
37
37
  */
38
38
  export declare const System: Schema.TaggedStruct<"System", {
39
- readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
39
+ readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
40
40
  readonly ref: Schema.optional<Schema.Struct<{
41
41
  readonly tenant: Schema.NonEmptyString;
42
42
  readonly actor: Schema.NonEmptyString;
@@ -54,7 +54,7 @@ export declare const System: Schema.TaggedStruct<"System", {
54
54
  export declare const Caller: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"User", {
55
55
  readonly subject: Schema.NonEmptyString;
56
56
  }>, Schema.TaggedStruct<"Anonymous", {}>, Schema.TaggedStruct<"System", {
57
- readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
57
+ readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
58
58
  readonly ref: Schema.optional<Schema.Struct<{
59
59
  readonly tenant: Schema.NonEmptyString;
60
60
  readonly actor: Schema.NonEmptyString;
@@ -80,7 +80,7 @@ export declare const CurrentCaller: Context.Reference<{
80
80
  readonly _tag: "Anonymous";
81
81
  } | {
82
82
  readonly _tag: "System";
83
- readonly source: "actor" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
83
+ readonly source: "actor" | "cold" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
84
84
  readonly ref?: {
85
85
  readonly tenant: string;
86
86
  readonly actor: string;
@@ -20,13 +20,22 @@ export const MintProof = Schema.Struct({
20
20
  * A caller the framework creates for its own deliveries, and the caller of
21
21
  * code that runs in the application's own process. `source` names the
22
22
  * mechanism (the process itself, an actor's intent, a timer, cron, a workflow,
23
- * a job route, or a subscription), `ref` the actor that sent it, and
23
+ * a job route, a subscription, or cold-state maintenance), `ref` the actor that sent it, and
24
24
  * `onBehalfOf` the principal of the turn that caused it. `mint` is set only on
25
25
  * a minted child's creating intent, and proves the child's id was derived by
26
26
  * its parent.
27
27
  */
28
28
  export const System = Schema.TaggedStruct("System", {
29
- source: Schema.Literals(["process", "actor", "timer", "cron", "workflow", "job", "subscription"]),
29
+ source: Schema.Literals([
30
+ "process",
31
+ "actor",
32
+ "timer",
33
+ "cron",
34
+ "workflow",
35
+ "job",
36
+ "subscription",
37
+ "cold",
38
+ ]),
30
39
  ref: Schema.optional(ActorRef),
31
40
  onBehalfOf: Schema.optional(Principal),
32
41
  mint: Schema.optional(MintProof),
@@ -28,7 +28,7 @@ export declare const provesMint: (caller: {
28
28
  readonly _tag: "Anonymous";
29
29
  } | {
30
30
  readonly _tag: "System";
31
- readonly source: "actor" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
31
+ readonly source: "actor" | "cold" | "cron" | "job" | "process" | "subscription" | "timer" | "workflow";
32
32
  readonly ref?: {
33
33
  readonly tenant: string;
34
34
  readonly actor: string;
@@ -8,6 +8,8 @@ import type { AnyCommand } from "../members/command.ts";
8
8
  export interface Policy {
9
9
  /** Idle time before the activation sleeps. Default 60 seconds. */
10
10
  readonly hibernateAfter?: Duration.Input;
11
+ /** Continuous sleep before state and actor blobs may be offloaded. Default 30 days; never opts out. */
12
+ readonly coldAfter?: Duration.Input | "never";
11
13
  /**
12
14
  * Deadline for one command turn's transaction and one query's execution.
13
15
  * It does not bound a workflow or a live session. Default 30 seconds.
@@ -95,6 +97,7 @@ export interface TurnPolicy {
95
97
  readonly deliveryMs: number;
96
98
  readonly stateMaxBytes: number;
97
99
  readonly idleMs: number;
100
+ readonly coldMs: number | undefined;
98
101
  readonly mailboxCapacity: number | "unbounded";
99
102
  readonly createdBy: string | undefined;
100
103
  readonly keepReceiptsMs: number;
@@ -20,6 +20,7 @@ export const resolvePolicy = (policy) => {
20
20
  deliveryMs: milliseconds(declared?.deliveryTimeout ?? "30 seconds"),
21
21
  stateMaxBytes: Positive.make(declared?.maxStateBytes ?? 65_536),
22
22
  idleMs: milliseconds(declared?.hibernateAfter ?? "60 seconds"),
23
+ coldMs: declared?.coldAfter === "never" ? undefined : horizon(declared?.coldAfter ?? "30 days"),
23
24
  mailboxCapacity: declared?.mailboxCapacity === undefined
24
25
  ? "unbounded"
25
26
  : Positive.make(declared.mailboxCapacity),
@@ -7,6 +7,8 @@ import { ConnectionPhase, } from "../members.js";
7
7
  import { Caller, principal } from "../../identity/caller.js";
8
8
  import { replayEvents } from "../events/replay.js";
9
9
  import { compress, decompress } from "../storage/codec.js";
10
+ import { ColdTier } from "../storage/cold-tier.js";
11
+ import { ColdRead } from "../turn/blobs.js";
10
12
  import { FrameworkClock } from "../turn/admission.js";
11
13
  import { actorRow, emptyActivationCache, forget as forgetGeneration, heldGeneration, } from "../storage/generation.js";
12
14
  import { Committed, FEED_MEMBER, FeedFrame, HolderItem, isWatchMember, watchedQuery, watchMember, } from "./protocol.js";
@@ -191,7 +193,18 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
191
193
  return;
192
194
  activations.delete(entityId);
193
195
  yield* seal(activation);
196
+ yield* scheduleCold(activation);
194
197
  }));
198
+ const scheduleCold = (activation) => Effect.gen(function* () {
199
+ const tier = yield* ColdTier;
200
+ const generation = activation.cache.generation;
201
+ const delay = registration.policy.coldMs;
202
+ if (tier === undefined || generation === undefined || delay === undefined)
203
+ return;
204
+ yield* tier
205
+ .hibernate(activation, generation, delay)
206
+ .pipe(Effect.catchCause((cause) => Effect.logWarning("Cold timer could not be scheduled", cause)));
207
+ });
195
208
  const channelOf = (activation, holder, epoch) => {
196
209
  const name = `${holder}|${epoch}`;
197
210
  const found = activation.channels.get(name);
@@ -255,11 +268,13 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
255
268
  activation.channels.clear();
256
269
  };
257
270
  /** Fences this activation's generation and loads committed state, as a command turn would. */
258
- const acquire = (activation) => activation.cache.generation !== undefined && activation.cache.state !== undefined
271
+ const acquire = (activation, fetch = true) => activation.cache.generation !== undefined &&
272
+ (activation.cache.state !== undefined || activation.cache.cold !== undefined)
259
273
  ? Effect.void
260
- : activation.acquiring.withPermit(acquireOnce(activation));
261
- const acquireOnce = (activation) => Effect.gen(function* () {
262
- if (activation.cache.generation !== undefined && activation.cache.state !== undefined)
274
+ : activation.acquiring.withPermit(acquireOnce(activation, fetch));
275
+ const acquireOnce = (activation, fetch) => Effect.gen(function* () {
276
+ if (activation.cache.generation !== undefined &&
277
+ (activation.cache.state !== undefined || activation.cache.cold !== undefined))
263
278
  return;
264
279
  const sql = yield* SqlClient.SqlClient;
265
280
  const actor = actorRow({ sql, actor: activation });
@@ -270,10 +285,10 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
270
285
  const [row] = activation.cache.generation === undefined
271
286
  ? yield* sql `
272
287
  UPDATE actor_generations SET generation = generation + 1 WHERE ${actor}
273
- RETURNING generation::text AS generation, event_sequence::text AS head`
288
+ RETURNING generation::text AS generation, event_sequence::text AS head, cold_ref, cold_digest, cold_state_version`
274
289
  : yield* sql `
275
- SELECT generation::text AS generation, event_sequence::text AS head
276
- FROM actor_generations WHERE ${actor}`;
290
+ SELECT generation::text AS generation, event_sequence::text AS head, cold_ref, cold_digest, cold_state_version
291
+ FROM actor_generations WHERE ${actor} FOR UPDATE`;
277
292
  if (activation.cache.generation !== undefined &&
278
293
  activation.cache.generation !== row.generation) {
279
294
  forget(activation);
@@ -281,10 +296,35 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
281
296
  }
282
297
  const state = yield* sql `
283
298
  SELECT key, value FROM actor_state WHERE ${actor}`;
299
+ yield* sql `DELETE FROM actor_outbox WHERE ${actor} AND timer_key = '$cold'`;
284
300
  return { row: row, state };
285
301
  }));
286
302
  activation.cache.generation = acquired.row.generation;
287
- activation.cache.state = new Map(acquired.state.map(({ key, value }) => [key, decompress(value)]));
303
+ if (acquired.row.cold_ref === null) {
304
+ activation.cache.state = new Map(acquired.state.map(({ key, value }) => [key, decompress(value)]));
305
+ activation.cache.cold = undefined;
306
+ }
307
+ else if (fetch) {
308
+ const tier = yield* ColdTier;
309
+ if (tier === undefined)
310
+ return yield* unavailable("Cold actor needs coldStorage");
311
+ const material = yield* tier
312
+ .fetch(activation, {
313
+ ref: acquired.row.cold_ref,
314
+ digest: acquired.row.cold_digest,
315
+ version: acquired.row.cold_state_version,
316
+ })
317
+ .pipe(Effect.catch(() => unavailable("Cold object unavailable")));
318
+ const matching = yield* sql `SELECT 1 FROM actor_generations WHERE ${actor}
319
+ AND generation = ${acquired.row.generation} AND cold_ref = ${material.ref}
320
+ AND cold_digest = ${material.digest} AND cold_state_version = ${material.version}`;
321
+ if (matching.length === 0) {
322
+ forget(activation);
323
+ return yield* unavailable("Cold read lost its generation");
324
+ }
325
+ activation.cache.cold = material;
326
+ activation.cache.state = undefined;
327
+ }
288
328
  advance(activation, acquired.row.head);
289
329
  activation.through = acquired.row.head;
290
330
  });
@@ -321,7 +361,7 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
321
361
  }).pipe(Effect.catchIf(SqlError.isSqlError, Effect.die));
322
362
  /** Readies an activation that is about to run a turn: fenced, with its connection rows. */
323
363
  const prepare = (activation) => hasConnections
324
- ? Effect.andThen(acquire(activation), load(activation)).pipe(Effect.orDie)
364
+ ? Effect.andThen(acquire(activation, false), load(activation)).pipe(Effect.orDie)
325
365
  : Effect.void;
326
366
  const list = (activation) => (member) => Effect.sync(() => [...(activation.rows?.values() ?? [])]
327
367
  .filter((row) => row.member === member && row.buffered === undefined)
@@ -413,19 +453,23 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
413
453
  const run = (activation, row, phase, commands) => Effect.gen(function* () {
414
454
  const connection = registration.connections.get(row.member);
415
455
  const sql = yield* SqlClient.SqlClient;
416
- return yield* connection.run({
456
+ return yield* connection
457
+ .run({
417
458
  ref: activation.ref,
418
459
  connectionId: row.connectionId,
419
460
  member: row.member,
420
461
  caller: row.caller,
421
462
  resumed: !ConnectionPhase.guards.Open(phase) && !activation.opened.has(row.connectionId),
422
463
  cursor: activation.through,
423
- state: [...(activation.cache.state ?? new Map())],
464
+ state: activation.cache.cold?.envelope.state ?? [
465
+ ...(activation.cache.state ?? new Map()),
466
+ ],
424
467
  session: row.session,
425
468
  connections: list(activation),
426
469
  events: events(activation, sql),
427
470
  commands,
428
- }, phase);
471
+ }, phase)
472
+ .pipe(Effect.provideService(ColdRead, activation.cache.cold));
429
473
  });
430
474
  const checkSession = (result) => result.session !== undefined &&
431
475
  utf8.encode(result.session).byteLength - SESSION_ENVELOPE_BYTES > MAX_SESSION_BYTES
@@ -675,7 +719,9 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
675
719
  return {
676
720
  ref: activation.ref,
677
721
  cursor: activation.head,
678
- state: [...(activation.cache.state ?? new Map())],
722
+ state: activation.cache.cold?.envelope.state ?? [
723
+ ...(activation.cache.state ?? new Map()),
724
+ ],
679
725
  events: events(activation, sql),
680
726
  follow: follow(activation, sql),
681
727
  progress: (tag, jobId) => progressFeed(activation, tag, jobId),
@@ -863,6 +909,7 @@ export const activationOwner = ({ registration, transport, authorize, clock, rol
863
909
  return;
864
910
  yield* streams.end(activation);
865
911
  yield* seal(activation);
912
+ yield* scheduleCold(activation);
866
913
  forget(activation);
867
914
  activation.opened.clear();
868
915
  activation.progress.clear();
@@ -334,7 +334,7 @@ export declare const connectionsEntity: (name: string) => Entity.Entity<`${strin
334
334
  caller: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"User", {
335
335
  readonly subject: Schema.NonEmptyString;
336
336
  }>, Schema.TaggedStruct<"Anonymous", {}>, Schema.TaggedStruct<"System", {
337
- readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
337
+ readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
338
338
  readonly ref: Schema.optional<Schema.Struct<{
339
339
  readonly tenant: Schema.NonEmptyString;
340
340
  readonly actor: Schema.NonEmptyString;
@@ -411,7 +411,7 @@ export declare const connectionsEntity: (name: string) => Entity.Entity<`${strin
411
411
  caller: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"User", {
412
412
  readonly subject: Schema.NonEmptyString;
413
413
  }>, Schema.TaggedStruct<"Anonymous", {}>, Schema.TaggedStruct<"System", {
414
- readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
414
+ readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
415
415
  readonly ref: Schema.optional<Schema.Struct<{
416
416
  readonly tenant: Schema.NonEmptyString;
417
417
  readonly actor: Schema.NonEmptyString;
@@ -1,6 +1,7 @@
1
1
  import { Cause, Effect, Exit, Fiber, Queue, Schedule, Stream } from "effect";
2
2
  import { ActorError, RunnerAtCapacity, SessionEnded, Unauthorized } from "../../errors/actor.js";
3
3
  import { StreamFailed, StreamItem } from "./protocol.js";
4
+ import { ColdRead } from "../turn/blobs.js";
4
5
  /** Open stream subscriptions one actor may have. */
5
6
  const MAX_ACTOR_STREAMS = 256;
6
7
  /** Stream elements held for one subscriber before its handler waits. */
@@ -76,7 +77,7 @@ export const streamSubscriptions = ({ registration, activations, transport, auth
76
77
  yield* Queue.offer(queue, item);
77
78
  subscription.stalledSince = undefined;
78
79
  });
79
- producer = yield* stream.run(request.input, { ...input, caller: request.caller }).pipe(Stream.runForEach((value) => offer(StreamItem.cases.Element.make({ value }))), Effect.andThen(offer(StreamItem.cases.Done.make({}))), Effect.andThen(Queue.end(queue)), Effect.catch(({ failure }) => Queue.fail(queue, StreamFailed.make({ value: failure }))), Effect.catchDefect((cause) => Effect.andThen(Effect.logError("Stream handler defect", Cause.die(cause)), Queue.fail(queue, ended("Defect", false)))), Effect.asVoid, Effect.ensuring(Effect.sync(() => {
80
+ producer = yield* stream.run(request.input, { ...input, caller: request.caller }).pipe(Stream.provideService(ColdRead, activation.cache.cold), Stream.runForEach((value) => offer(StreamItem.cases.Element.make({ value }))), Effect.andThen(offer(StreamItem.cases.Done.make({}))), Effect.andThen(Queue.end(queue)), Effect.catch(({ failure }) => Queue.fail(queue, StreamFailed.make({ value: failure }))), Effect.catchDefect((cause) => Effect.andThen(Effect.logError("Stream handler defect", Cause.die(cause)), Queue.fail(queue, ended("Defect", false)))), Effect.asVoid, Effect.ensuring(Effect.sync(() => {
80
81
  closed = true;
81
82
  activation.streams.delete(subscription);
82
83
  })), Effect.forkDetach);
@@ -1,5 +1,5 @@
1
1
  import { PgClient, PgPool } from "@effect/sql-pg";
2
- import { Duration, Effect, Schedule, Schema, Scope } from "effect";
2
+ import { Duration, Effect, Schedule, Schema, Scope, Semaphore, Stream } from "effect";
3
3
  import { SqlClient, SqlError, Statement } from "effect/sql";
4
4
  import { admissionLimit, isOverloaded } from "../admission.js";
5
5
  import { ActorError } from "../../errors/actor.js";
@@ -49,9 +49,24 @@ const boundedSessions = Effect.fnUntraced(function* (options) {
49
49
  const fair = fairGate(slots);
50
50
  const enter = Effect.andThen(admission.take.pipe(Effect.mapError(poolRefusal)), fair.take);
51
51
  const connection = (session) => asSqlConnection({ connection: session, send: direct });
52
+ /** A reservation cannot return to the pool while an interrupted statement is still draining its wire response. */
53
+ const reserve = Effect.gen(function* () {
54
+ yield* enter;
55
+ const session = yield* pool.reserve;
56
+ const statements = Semaphore.makeUnsafe(1);
57
+ let closing = false;
58
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
59
+ closing = true;
60
+ }).pipe(Effect.andThen(statements.withPermit(Effect.void))));
61
+ return asSqlConnection({
62
+ connection: session,
63
+ send: (statement) => statements.withPermit(Effect.suspend(() => (closing ? Effect.interrupt : statement))),
64
+ sendStream: (stream) => Stream.unwrap(Effect.acquireRelease(statements.take(1), () => statements.release(1)).pipe(Effect.andThen(Effect.suspend(() => (closing ? Effect.interrupt : Effect.succeed(stream)))))),
65
+ });
66
+ });
52
67
  return {
53
68
  acquire: Effect.andThen(enter, Effect.map(pool.get, connection)),
54
- reserve: Effect.andThen(enter, Effect.map(pool.reserve, connection)),
69
+ reserve,
55
70
  borrow: (use) => admission
56
71
  .admit(fair.use(pool.use((session) => use(connection(session)))))
57
72
  .pipe(Effect.mapError((error) => (Schema.is(ActorError)(error) ? poolRefusal(error) : error))),
@@ -1,8 +1,9 @@
1
1
  import type { PgConnection } from "@effect/sql-pg";
2
- import { Effect } from "effect";
2
+ import { Effect, Stream } from "effect";
3
3
  import type { SqlConnection, SqlError } from "effect/sql";
4
4
  /** A leased session as a `SqlClient` connection, so the runtime's statements reach it. */
5
- export declare const asSqlConnection: ({ connection, send, }: {
5
+ export declare const asSqlConnection: ({ connection, send, sendStream, }: {
6
6
  readonly connection: PgConnection.PgConnection;
7
7
  readonly send: <A>(statement: Effect.Effect<A, SqlError.SqlError>) => Effect.Effect<A, SqlError.SqlError>;
8
+ readonly sendStream?: <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<A, E, R>;
8
9
  }) => SqlConnection.Connection;
@@ -1,6 +1,6 @@
1
1
  import { Effect, Stream } from "effect";
2
2
  /** A leased session as a `SqlClient` connection, so the runtime's statements reach it. */
3
- export const asSqlConnection = ({ connection, send, }) => {
3
+ export const asSqlConnection = ({ connection, send, sendStream, }) => {
4
4
  const rows = (prepare) => (sql, params, transformRows) => {
5
5
  const found = send(Effect.map(connection.query(sql, params, prepare), (result) => result.rows));
6
6
  return transformRows === undefined ? found : Effect.map(found, transformRows);
@@ -8,9 +8,14 @@ export const asSqlConnection = ({ connection, send, }) => {
8
8
  return {
9
9
  execute: rows(true),
10
10
  executeRaw: (sql, params) => send(connection.query(sql, params)),
11
- executeStream: (sql, params, transformRows) => Stream.unwrap(Effect.as(send(Effect.void), transformRows === undefined
12
- ? connection.stream(sql, params)
13
- : Stream.map(connection.stream(sql, params), (row) => transformRows([row])[0]))),
11
+ executeStream: (sql, params, transformRows) => {
12
+ const stream = transformRows === undefined
13
+ ? connection.stream(sql, params)
14
+ : Stream.map(connection.stream(sql, params), (row) => transformRows([row])[0]);
15
+ return sendStream === undefined
16
+ ? Stream.unwrap(Effect.as(send(Effect.void), stream))
17
+ : sendStream(stream);
18
+ },
14
19
  executeValues: (sql, params) => send(connection.queryValues(sql, params)),
15
20
  executeValuesUnprepared: (sql, params) => send(connection.queryValues(sql, params, false)),
16
21
  executeUnprepared: rows(false),
@@ -1,4 +1,4 @@
1
- import { PgClient } from "@effect/sql-pg";
1
+ import type { PgClient } from "@effect/sql-pg";
2
2
  import { Context, Effect, Layer, type Scope } from "effect";
3
3
  import { SqlClient } from "effect/sql";
4
4
  /** The deployment's coordination database, shared by every runner. */
@@ -7,8 +7,11 @@ export declare const Coordination: Context.Reference<SqlClient.SqlClient | undef
7
7
  export declare const registry: Context.Service<SqlClient.SqlClient, SqlClient.SqlClient>;
8
8
  /** The client deployment-wide locks are taken on: the designated coordination pool, else `registry`. */
9
9
  export declare const authority: Effect.Effect<SqlClient.SqlClient, never, SqlClient.SqlClient>;
10
- /** Holds startup coordination before even the history table is created, on one leased session. */
11
- export declare const withMigrationCoordination: <A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient | Scope.Scope>) => Effect.Effect<A, E | import("effect/sql/SqlError").SqlError, SqlClient.SqlClient>;
10
+ /** Holds startup coordination, history creation and migrations in one bounded transaction. */
11
+ export declare const withMigrationCoordination: <A, E>({ effect, history, }: {
12
+ readonly effect: Effect.Effect<A, E, SqlClient.SqlClient | Scope.Scope>;
13
+ readonly history?: "actor_migrations" | "actor_coordination_migrations";
14
+ }) => Effect.Effect<A, E | import("effect/sql/SqlError").SqlError, SqlClient.SqlClient>;
12
15
  /** Supplies an independent client so coordination transactions never borrow a data transaction's connection. */
13
16
  export declare const coordinationLayer: (options: PgClient.PgPoolConfig | undefined) => Layer.Layer<never, import("effect/sql/SqlError").SqlError, never>;
14
17
  /**
@@ -1,4 +1,3 @@
1
- import { PgClient } from "@effect/sql-pg";
2
1
  import { Context, Effect, Layer } from "effect";
3
2
  import { Reactivity } from "effect/reactivity";
4
3
  import { Migrator, SqlClient } from "effect/sql";
@@ -16,30 +15,32 @@ const prepareCoordination = Effect.gen(function* () {
16
15
  resource text PRIMARY KEY
17
16
  )`;
18
17
  });
19
- /** Holds startup coordination before even the history table is created, on one leased session. */
20
- export const withMigrationCoordination = (effect) => Effect.scoped(Effect.gen(function* () {
18
+ /** Holds startup coordination, history creation and migrations in one bounded transaction. */
19
+ export const withMigrationCoordination = ({ effect, history, }) => Effect.scoped(Effect.gen(function* () {
21
20
  const source = yield* SqlClient.SqlClient;
22
- const connection = yield* source.reserve;
23
- yield* Effect.acquireRelease(connection.execute("SELECT pg_advisory_lock(1935764837, 487)", [], undefined), () => connection
24
- .execute("SELECT pg_advisory_unlock(1935764837, 487)", [], undefined)
25
- .pipe(Effect.orDie));
26
- const reactivity = yield* Reactivity.make;
27
- const client = yield* SqlClient.make({
28
- acquirer: Effect.succeed(connection),
29
- compiler: PgClient.makeCompiler(),
30
- spanAttributes: [],
31
- }).pipe(Effect.provideService(Reactivity.Reactivity, reactivity));
32
- return yield* Effect.provideService(effect, SqlClient.SqlClient, client);
21
+ return yield* source.withTransaction(Effect.gen(function* () {
22
+ yield* source `SELECT pg_advisory_xact_lock(1935764837, 487)`;
23
+ if (history !== undefined)
24
+ yield* source `CREATE TABLE IF NOT EXISTS ${source(history)} (
25
+ migration_id integer primary key,
26
+ created_at timestamp with time zone not null default now(),
27
+ name text not null
28
+ )`;
29
+ return yield* effect;
30
+ }));
33
31
  }));
34
32
  /** Supplies an independent client so coordination transactions never borrow a data transaction's connection. */
35
33
  export const coordinationLayer = (options) => Layer.effect(Coordination, Effect.gen(function* () {
36
34
  if (options === undefined)
37
35
  return undefined;
38
36
  const sql = yield* boundedPool(options);
39
- yield* withMigrationCoordination(Migrator.make({})({
40
- table: "actor_coordination_migrations",
41
- loader: Migrator.fromRecord({ "0001_coordination": prepareCoordination }),
42
- })).pipe(Effect.provideService(SqlClient.SqlClient, sql), Effect.orDie);
37
+ yield* withMigrationCoordination({
38
+ effect: Migrator.make({})({
39
+ table: "actor_coordination_migrations",
40
+ loader: Migrator.fromRecord({ "0001_coordination": prepareCoordination }),
41
+ }),
42
+ history: "actor_coordination_migrations",
43
+ }).pipe(Effect.provideService(SqlClient.SqlClient, sql), Effect.orDie);
43
44
  return sql;
44
45
  })).pipe(Layer.provide(Reactivity.layer));
45
46
  /**
@@ -196,6 +196,10 @@ export declare const migrations: {
196
196
  "0032_authority_placement": Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
197
197
  /** Retires the single-table inspection variants without rewriting the applied migration that created them. */
198
198
  "0033_joined_inspection": Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
199
+ /** Cold pointers replace state and blob rows atomically; garbage rows never authorize deletion by themselves. */
200
+ "0034_cold_tier": Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
201
+ /** The transaction's generation lock, not a referential trigger, authorizes state and receipt writes. */
202
+ "0035_hot_table_foreign_keys": Effect.Effect<void, Migrator.MigrationError | SqlError.SqlError, SqlClient.SqlClient>;
199
203
  };
200
204
  /**
201
205
  * Runs `record` like `Migrator`, but first refuses a database where a