@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
@@ -5,6 +5,7 @@ import { Actors } from "../handles/actors.ts";
5
5
  import { InternalActors } from "./actors.ts";
6
6
  import type { AccessRequest } from "../policies/access.ts";
7
7
  import { pglite } from "./database/pglite.ts";
8
+ import { type ColdStorageOptions } from "./storage/cold-tier.ts";
8
9
  import { DefectLog } from "./telemetry/defects.ts";
9
10
  import { OperatorRuntime } from "./operators/repair.ts";
10
11
  import { TelemetrySampler } from "./telemetry/sampler.ts";
@@ -15,6 +16,8 @@ import { RuntimeControl } from "./drain.ts";
15
16
  import type { AnyFleetView } from "../tables/fleet.ts";
16
17
  /** Configuration for `Actors.layer`: authorization, actor and effect layers, timing, retention, and row-level security. */
17
18
  export interface Options {
19
+ /** Opt-in cold state and actor blobs. Postgres only; backup retention is required. */
20
+ readonly coldStorage?: ColdStorageOptions;
18
21
  /**
19
22
  * The global authorization hook, asked about every external request beside
20
23
  * the actor's own `access` policy; when both exist both must allow. With
@@ -223,6 +226,10 @@ export interface Options {
223
226
  */
224
227
  export declare const layer: (options?: Options) => Layer.Layer<Actors | ContentStore | DefectLog | InternalActors | OperatorRuntime | RuntimeControl | TelemetrySampler, import("effect/sql/Migrator").MigrationError | SqlError.SqlError, Crypto.Crypto | SqlClient.SqlClient>;
225
228
  type PostgresOptions = Omit<PgClient.PgPoolConfig, "types"> & {
229
+ /** Opt-in transaction pooling for socket runners with table leases; session-dependent features are refused. */
230
+ readonly pooling?: "transaction";
231
+ /** Four primary sessions: one turn, two off-turn (one reserved by Cluster), and one query. Explicit pool sizes take precedence. */
232
+ readonly preset?: "low-connection";
226
233
  readonly offTurnConnections?: number;
227
234
  readonly queryConnections?: number;
228
235
  readonly replica?: Omit<PgClient.PgPoolConfig, "types"> | undefined;
@@ -245,11 +252,27 @@ type PostgresOptions = Omit<PgClient.PgPoolConfig, "types"> & {
245
252
  * first come, first served. Keep the sum across runners below the server's
246
253
  * `max_connections`.
247
254
  *
255
+ * `preset: "low-connection"` defaults these pools to 1/2/1 instead of
256
+ * 50/10/10. Cluster reserves one off-turn session for its entire lifetime,
257
+ * including with table leases, so the other must remain available for
258
+ * startup and ordinary work. Fleet maintenance and application reservations
259
+ * need additional off-turn capacity; optional pools add to the budget.
260
+ * Explicit pool sizes override the preset.
261
+ *
262
+ * `pooling: "transaction"` explicitly selects a transaction-pooled endpoint.
263
+ * It requires `Runner.socket` table leases, refuses replica, separate
264
+ * coordination, fleet and cold storage, disables prepared statements and
265
+ * warm turn speculation, and does not inject server keepalive startup
266
+ * parameters. Configure both proxy transport legs separately. Cluster's
267
+ * reservation consumes a logical client, not a backend outside transactions.
268
+ * Application session state, `listen` and session advisory locks remain
269
+ * unsupported on this endpoint.
270
+ *
248
271
  * `replica` is this runner's nearest streaming replica of the same primary.
249
272
  * Queries read there once it has replayed the commit version their caller
250
273
  * last saw, and read the primary when it is behind or fails. Its pool
251
274
  * (`maxConnections` default 10) opens connections only as queries need them.
252
- * Every pool requests server TCP keepalives at 5 seconds idle, 2 seconds
275
+ * Direct pools request server TCP keepalives at 5 seconds idle, 2 seconds
253
276
  * between probes, and 3 probes. `startupParameters` overrides these defaults
254
277
  * independently on the primary and replica configurations.
255
278
  *
@@ -15,6 +15,7 @@ import { schemaChange } from "./database/schema.js";
15
15
  import { queryPoolLayer, ReadReplica, replicaLayer } from "./database/replica.js";
16
16
  import { Coordination, coordinationLayer } from "./database/coordination.js";
17
17
  import { withKeepalives } from "./database/keepalive.js";
18
+ import { WarmTurnFastPath } from "./turn/execute.js";
18
19
  import { checkRowLevelSecurity, TenantScope } from "./database/tenancy.js";
19
20
  import { retryDelay } from "./retry.js";
20
21
  import { withoutDatabase } from "./jobs/isolation.js";
@@ -26,6 +27,7 @@ import { ProgressSink, ProgressTap } from "./jobs/progress.js";
26
27
  import { FEED_MEMBER, isWatchMember, watchedQuery } from "./connections/protocol.js";
27
28
  import { checkIdentity, databaseTime, FrameworkClock } from "./turn/admission.js";
28
29
  import { routingKey } from "./storage/codec.js";
30
+ import { ColdTier, coldTier } from "./storage/cold-tier.js";
29
31
  import { recordedPlacement } from "./storage/placements.js";
30
32
  import { TurnHooks } from "./turn/hooks.js";
31
33
  import { requestAttributes, SpanNames } from "./telemetry/spans.js";
@@ -220,7 +222,17 @@ export const layer = (options = {}) => {
220
222
  const frameworkClock = yield* FrameworkClock;
221
223
  const writerDeclarations = [];
222
224
  let refreshedAt;
223
- const services = yield* Effect.context();
225
+ let services = yield* Effect.context();
226
+ if (options.coldStorage !== undefined &&
227
+ Option.isSome(yield* Effect.serviceOption(PgliteClient.PgliteClient)))
228
+ return yield* Effect.die(new Error("coldStorage requires Postgres, not PGlite"));
229
+ const tier = options.coldStorage === undefined
230
+ ? undefined
231
+ : yield* coldTier(options.coldStorage, (yield* (yield* SqlClient.SqlClient) `SELECT deployment_id FROM actor_deployment`)[0].deployment_id, relaySettings.maxBackoffMs);
232
+ services = Context.add(services, ColdTier, tier);
233
+ const coldSweep = tier?.periodic === true
234
+ ? yield* tier.run.pipe(Effect.provideContext(services), Effect.forkIn(scope))
235
+ : undefined;
224
236
  const defectLog = boundedDefectLog(defectCapacity);
225
237
  let recorder;
226
238
  const currentRecorder = () => recorder;
@@ -582,7 +594,7 @@ export const layer = (options = {}) => {
582
594
  }, () => new Map(Array.from(registrations.values(), ({ name, cron, policy }) => [
583
595
  name,
584
596
  { entries: cron, skipMs: policy.cronSkipMs },
585
- ]))).pipe(Effect.provideService(ProgressSink, progressSink));
597
+ ]))).pipe(Effect.provideService(ProgressSink, progressSink), Effect.provideService(ColdTier, tier));
586
598
  yield* relay.run.pipe(Effect.forkIn(scope));
587
599
  let fleetSubscribe;
588
600
  if (options.fleet !== undefined && options.fleet.length > 0) {
@@ -785,7 +797,7 @@ export const layer = (options = {}) => {
785
797
  stopClaims: relay.stop,
786
798
  attemptsIdle: relay.attemptsIdle,
787
799
  interruptAttempts: relay.interruptAttempts,
788
- stopBackground: sweeping === undefined ? Effect.void : Fiber.interrupt(sweeping),
800
+ stopBackground: Effect.forEach([sweeping, coldSweep].filter((fiber) => fiber !== undefined), Fiber.interrupt, { discard: true }),
789
801
  serving,
790
802
  scope,
791
803
  });
@@ -839,12 +851,19 @@ export const layer = (options = {}) => {
839
851
  }));
840
852
  }),
841
853
  });
842
- return Context.make(Actors, publicActors).pipe(Context.add(ContentStore, contentStore), Context.add(InternalActors, internalActors), Context.add(RuntimeControl, control), Context.add(DefectLog, defectLog), Context.add(TelemetrySampler, TelemetrySampler.of({ sample })), Context.add(OperatorRuntime, operators));
854
+ return Context.make(Actors, publicActors).pipe(Context.add(ColdTier, tier), Context.add(ContentStore, contentStore), Context.add(InternalActors, internalActors), Context.add(RuntimeControl, control), Context.add(DefectLog, defectLog), Context.add(TelemetrySampler, TelemetrySampler.of({ sample })), Context.add(OperatorRuntime, operators));
843
855
  }));
844
856
  return Layer.unwrap(Effect.gen(function* () {
845
857
  const sql = yield* SqlClient.SqlClient;
846
858
  const coordination = (yield* Coordination) ?? sql;
847
859
  const wiring = Option.getOrUndefined(yield* Effect.serviceOption(RunnerWiring));
860
+ const transactionPooling = yield* TransactionPooling;
861
+ if (transactionPooling &&
862
+ (wiring?.production !== true ||
863
+ wiring.config?.shardLockDisableAdvisory !== true ||
864
+ options.coldStorage !== undefined ||
865
+ (options.fleet?.length ?? 0) > 0))
866
+ return yield* Effect.die(new Error("Transaction pooling requires Runner.socket table leases and does not support coldStorage or fleet"));
848
867
  yield* migrate;
849
868
  if (options.rowLevelSecurity !== undefined &&
850
869
  options.adoption !== undefined &&
@@ -889,7 +908,7 @@ export const layer = (options = {}) => {
889
908
  expiration: Duration.fromInputUnsafe(config.shardLockExpiration ?? ShardingConfig.defaults.shardLockExpiration),
890
909
  }))
891
910
  : Layer.empty;
892
- return runtime.pipe(Layer.provide(sharding), Layer.provide(lease), Layer.provide(Layer.succeed(TenantScope, {
911
+ return runtime.pipe(Layer.provide(transactionPooling ? Layer.succeed(WarmTurnFastPath, false) : Layer.empty), Layer.provide(sharding), Layer.provide(lease), Layer.provide(Layer.succeed(TenantScope, {
893
912
  role: options.rowLevelSecurity?.role,
894
913
  adoption: options.adoption === undefined
895
914
  ? undefined
@@ -897,6 +916,10 @@ export const layer = (options = {}) => {
897
916
  })));
898
917
  }));
899
918
  };
919
+ /** Explicit pooling mode, independent of endpoint names or URL parameters. */
920
+ const TransactionPooling = Context.Reference("akter/TransactionPooling", {
921
+ defaultValue: () => false,
922
+ });
900
923
  /**
901
924
  * A runner holds three primary pools, plus optional replica and coordination
902
925
  * pools. Turns lease sessions from the turn pool, `maxConnections` (default
@@ -913,11 +936,27 @@ export const layer = (options = {}) => {
913
936
  * first come, first served. Keep the sum across runners below the server's
914
937
  * `max_connections`.
915
938
  *
939
+ * `preset: "low-connection"` defaults these pools to 1/2/1 instead of
940
+ * 50/10/10. Cluster reserves one off-turn session for its entire lifetime,
941
+ * including with table leases, so the other must remain available for
942
+ * startup and ordinary work. Fleet maintenance and application reservations
943
+ * need additional off-turn capacity; optional pools add to the budget.
944
+ * Explicit pool sizes override the preset.
945
+ *
946
+ * `pooling: "transaction"` explicitly selects a transaction-pooled endpoint.
947
+ * It requires `Runner.socket` table leases, refuses replica, separate
948
+ * coordination, fleet and cold storage, disables prepared statements and
949
+ * warm turn speculation, and does not inject server keepalive startup
950
+ * parameters. Configure both proxy transport legs separately. Cluster's
951
+ * reservation consumes a logical client, not a backend outside transactions.
952
+ * Application session state, `listen` and session advisory locks remain
953
+ * unsupported on this endpoint.
954
+ *
916
955
  * `replica` is this runner's nearest streaming replica of the same primary.
917
956
  * Queries read there once it has replayed the commit version their caller
918
957
  * last saw, and read the primary when it is behind or fails. Its pool
919
958
  * (`maxConnections` default 10) opens connections only as queries need them.
920
- * Every pool requests server TCP keepalives at 5 seconds idle, 2 seconds
959
+ * Direct pools request server TCP keepalives at 5 seconds idle, 2 seconds
921
960
  * between probes, and 3 probes. `startupParameters` overrides these defaults
922
961
  * independently on the primary and replica configurations.
923
962
  *
@@ -925,6 +964,9 @@ export const layer = (options = {}) => {
925
964
  * migrator needs it on restart; remove once Effect #8309 lands.
926
965
  */
927
966
  function postgres(options) {
967
+ if (options.pooling === "transaction" &&
968
+ (options.replica !== undefined || options.coordination !== undefined))
969
+ throw new Error("Transaction pooling does not support replica or separate coordination");
928
970
  const types = PgTypes.makeRegistry();
929
971
  types.register(2205, {
930
972
  encode: (value) => PgTypes.encode(value, PgTypes.OID.oid),
@@ -932,9 +974,22 @@ function postgres(options) {
932
974
  ? Result.succeed(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0))
933
975
  : Result.fail(new PgTypes.CodecError({ message: "Invalid regclass value" })),
934
976
  });
935
- const { offTurnConnections, queryConnections, replica, coordination, ...configured } = options;
936
- const pool = withKeepalives(configured);
937
- return Layer.mergeAll(boundedLayer({ ...pool, maxConnections: offTurnConnections ?? 10, types }), turnGroups.pipe(Layer.provideMerge(turnConnections({ ...pool, maxConnections: pool.maxConnections ?? 50, types }))), queryPoolLayer({ ...pool, maxConnections: queryConnections ?? 10, types }), replicaLayer(replica === undefined ? undefined : { ...withKeepalives(replica), types }), coordinationLayer(coordination === undefined ? undefined : { ...withKeepalives(coordination), types }));
977
+ const { pooling, preset, offTurnConnections, queryConnections, replica, coordination, ...configured } = options;
978
+ const pool = pooling === "transaction" ? { ...configured, prepare: false } : withKeepalives(configured);
979
+ const lowConnection = preset === "low-connection";
980
+ return Layer.mergeAll(Layer.succeed(TransactionPooling, pooling === "transaction"), boundedLayer({
981
+ ...pool,
982
+ maxConnections: offTurnConnections ?? (lowConnection ? 2 : 10),
983
+ types,
984
+ }), turnGroups.pipe(Layer.provideMerge(turnConnections({
985
+ ...pool,
986
+ maxConnections: pool.maxConnections ?? (lowConnection ? 1 : 50),
987
+ types,
988
+ }))), queryPoolLayer({
989
+ ...pool,
990
+ maxConnections: queryConnections ?? (lowConnection ? 1 : 10),
991
+ types,
992
+ }), replicaLayer(replica === undefined ? undefined : { ...withKeepalives(replica), types }), coordinationLayer(coordination === undefined ? undefined : { ...withKeepalives(coordination), types }));
938
993
  }
939
994
  /** Database layers for `Actors.layer`: `postgres` for real deployments, `pglite` for embedded and test use. */
940
995
  export const Database = {
@@ -279,6 +279,7 @@ export interface RegisteredQuery {
279
279
  /** What an actor type's query layer registers with the runtime. */
280
280
  export interface QueryRegistration {
281
281
  readonly name: string;
282
+ readonly stateVersion: number;
282
283
  readonly placement: Placement;
283
284
  /** `commandTimeout`: a query's reads are cancelled on the server past it. */
284
285
  readonly timeoutMs: number;
@@ -293,6 +294,7 @@ export interface QueryRegistration {
293
294
  /** What an actor type's command layer registers with the runtime. */
294
295
  export interface Registration {
295
296
  readonly name: string;
297
+ readonly stateVersion: number;
296
298
  readonly singleton: boolean;
297
299
  /** Unkeyed with `policy.createdBy`: its UUIDv8 ids come only from `turn.mint`. */
298
300
  readonly mintable: boolean;
@@ -21,7 +21,9 @@ export declare class ExportRefused extends ExportRefused_base {
21
21
  * them in `omitted`. Subscription cursors and connections are neither carried
22
22
  * nor counted. A job whose attempt has been cancelled or has finally failed
23
23
  * is not pending and is left out. It reads the runtime tables, not the
24
- * inspection views, because the views do not show a cancelled job. `None`
24
+ * inspection views, because the views do not show a cancelled job. A cold
25
+ * pointer is captured in that snapshot; its immutable material is fetched
26
+ * only after the transaction ends, without activation or write-back. `None`
25
27
  * when the tenant has no such actor.
26
28
  */
27
29
  export declare const exportActor: (page: {
@@ -5,6 +5,7 @@ import { JOB_KEY_PREFIX } from "../../handles/intents.js";
5
5
  import { decodeBytes, decodeText } from "../inspector/queries.js";
6
6
  import { inReadOnlySnapshot } from "../database/snapshot.js";
7
7
  import { TenantScope, tenantSettings } from "../database/tenancy.js";
8
+ import { ColdTier } from "../storage/cold-tier.js";
8
9
  import { databaseTime } from "../turn/admission.js";
9
10
  import { SEED_FORMAT } from "./seed.js";
10
11
  /** The most pending intents, and the most pending jobs, one export carries. */
@@ -41,18 +42,21 @@ const tenantSnapshot = (tenant) => (effect) => Effect.gen(function* () {
41
42
  * them in `omitted`. Subscription cursors and connections are neither carried
42
43
  * nor counted. A job whose attempt has been cancelled or has finally failed
43
44
  * is not pending and is left out. It reads the runtime tables, not the
44
- * inspection views, because the views do not show a cancelled job. `None`
45
+ * inspection views, because the views do not show a cancelled job. A cold
46
+ * pointer is captured in that snapshot; its immutable material is fetched
47
+ * only after the transaction ends, without activation or write-back. `None`
45
48
  * when the tenant has no such actor.
46
49
  */
47
50
  export const exportActor = (page) => tenantSnapshot(page.tenant)(Effect.gen(function* () {
48
51
  const sql = yield* SqlClient.SqlClient;
49
52
  const [found] = yield* sql `
50
- SELECT routing_key::text AS "routingKey", created FROM actor_generations
53
+ SELECT routing_key::text AS "routingKey", created, cold_ref, cold_digest, cold_state_version
54
+ FROM actor_generations
51
55
  WHERE tenant_id = ${page.tenant} AND actor_type = ${page.actorType}
52
56
  AND actor_id = ${page.actorId}`;
53
57
  if (found === undefined)
54
- return Option.none();
55
- const { routingKey, created } = found;
58
+ return undefined;
59
+ const { routingKey } = found;
56
60
  const owned = sql `routing_key = ${routingKey}::int8 AND tenant_id = ${page.tenant}
57
61
  AND actor_type = ${page.actorType} AND actor_id = ${page.actorId}`;
58
62
  const exportedAtMs = yield* databaseTime;
@@ -93,14 +97,36 @@ export const exportActor = (page) => tenantSnapshot(page.tenant)(Effect.gen(func
93
97
  AND actor_id = ${page.actorId}`;
94
98
  tableRows += rows.count;
95
99
  }
100
+ return { found, stored, intents, jobs, counted: counted, tableRows, exportedAtMs };
101
+ })).pipe(Effect.flatMap((snapshot) => Effect.gen(function* () {
102
+ if (snapshot === undefined)
103
+ return Option.none();
104
+ const { found, stored, intents, jobs, counted, tableRows, exportedAtMs } = snapshot;
105
+ const { routingKey, created } = found;
106
+ const material = found.cold_ref === null
107
+ ? undefined
108
+ : yield* Effect.flatMap(ColdTier, (tier) => tier === undefined
109
+ ? Effect.die(new Error("Cold export needs coldStorage"))
110
+ : tier
111
+ .fetch({
112
+ key: BigInt(routingKey),
113
+ ref: { tenant: page.tenant, actor: page.actorType, id: page.actorId },
114
+ }, {
115
+ ref: found.cold_ref,
116
+ digest: found.cold_digest,
117
+ version: found.cold_state_version,
118
+ })
119
+ .pipe(Effect.orDie));
120
+ const entries = material === undefined
121
+ ? stored.map(({ key, value }) => [key, decodeBytes(value)])
122
+ : material.envelope.state.map(([key, value]) => [key, decodeText(value)]);
96
123
  const state = {};
97
124
  let stateVersion = 0;
98
- for (const row of stored) {
99
- const decoded = decodeBytes(row.value);
125
+ for (const [key, decoded] of entries) {
100
126
  if (decoded === null || "undecodable" in decoded)
101
- return yield* ExportRefused.make({ reason: "undecodable", detail: `state ${row.key}` });
102
- if (row.key !== VERSION_KEY)
103
- state[row.key] = decoded.json;
127
+ return yield* ExportRefused.make({ reason: "undecodable", detail: `state ${key}` });
128
+ if (key !== VERSION_KEY)
129
+ state[key] = decoded.json;
104
130
  else
105
131
  stateVersion = Option.getOrElse(decodeVersion(decoded.json), () => 0);
106
132
  }
@@ -131,7 +157,12 @@ export const exportActor = (page) => tenantSnapshot(page.tenant)(Effect.gen(func
131
157
  key: row.timerKey?.replace(JOB_KEY_PREFIX, "") ?? undefined,
132
158
  dueInMs: dueIn(row.dueAtMs),
133
159
  }))),
134
- omitted: { ...counted, tableRows },
160
+ omitted: {
161
+ ...counted,
162
+ tableRows,
163
+ blobs: counted.blobs +
164
+ (material?.envelope.blobs.filter(({ chunk }) => chunk === 0).length ?? 0),
165
+ },
135
166
  };
136
167
  return Option.some(seed);
137
- }));
168
+ })));
@@ -4,7 +4,7 @@ import { SqlClient } from "effect/sql";
4
4
  import { ActorError } from "../errors/actor.ts";
5
5
  import { type InternalActors } from "./actors.ts";
6
6
  import { type Request } from "./request.ts";
7
- import { type QueryRegistration, type Registration } from "./members.ts";
7
+ import type { QueryRegistration, Registration } from "./members.ts";
8
8
  import type { Holder } from "./connections/holder.ts";
9
9
  export declare const committedReads: ({ registrations, queryRegistrations, services, allow, primary, replica, holder, }: {
10
10
  readonly registrations: ReadonlyMap<string, Registration>;
@@ -4,12 +4,13 @@ import { SqlClient, SqlError } from "effect/sql";
4
4
  import { ActorError, ActorUnavailable, InvalidInput, NotCreated, Timeout } from "../errors/actor.js";
5
5
  import {} from "./actors.js";
6
6
  import { Outcome } from "./request.js";
7
- import {} from "./members.js";
8
7
  import { watchStream } from "./connections/watch.js";
9
8
  import { caughtUp, QueryPool } from "./database/replica.js";
10
9
  import { withTenant } from "./database/tenancy.js";
11
10
  import { replayEvents } from "./events/replay.js";
12
11
  import { decompress, routingKey } from "./storage/codec.js";
12
+ import { ColdTier } from "./storage/cold-tier.js";
13
+ import { ColdRead } from "./turn/blobs.js";
13
14
  import { accountsUsage, UsageAccounting } from "./telemetry/usage.js";
14
15
  import { decodeResult } from "./workflows/engine.js";
15
16
  /**
@@ -57,15 +58,16 @@ export const committedReads = ({ registrations, queryRegistrations, services, al
57
58
  const key = routingKey({ ref: request.ref, placement: registration.placement });
58
59
  const read = (client) => Effect.gen(function* () {
59
60
  const rows = yield* client `
60
- SELECT event_sequence::text AS head, NULL AS key, NULL::bytea AS value
61
+ SELECT event_sequence::text AS head, NULL AS key, NULL::bytea AS value,
62
+ cold_ref, cold_digest, cold_state_version
61
63
  FROM actor_generations
62
64
  WHERE routing_key = ${key} AND tenant_id = ${request.ref.tenant}
63
65
  AND actor_type = ${request.ref.actor} AND actor_id = ${request.ref.id}
64
66
  UNION ALL
65
- SELECT NULL, key, value
67
+ SELECT NULL, key, value, NULL, NULL, NULL
66
68
  FROM actor_state
67
69
  WHERE routing_key = ${key} AND tenant_id = ${request.ref.tenant}
68
- AND actor_type = ${request.ref.actor} AND actor_id = ${request.ref.id}`;
70
+ AND actor_type = ${request.ref.actor} AND actor_id = ${request.ref.id}`.pipe(withTenant(request.ref.tenant));
69
71
  let head;
70
72
  const state = [];
71
73
  for (const row of rows)
@@ -76,11 +78,26 @@ export const committedReads = ({ registrations, queryRegistrations, services, al
76
78
  if (head === undefined)
77
79
  state.length = 0;
78
80
  const cursor = head ?? "0";
79
- const outcome = yield* query.run(request, state, cursor, (tag, after, limit) => replayEvents(request.ref, key, [tag], after, BigInt(cursor), limit).pipe(Effect.catchIf(SqlError.isSqlError, Effect.die), Effect.provideService(SqlClient.SqlClient, client), Effect.provideContext(services)), reads);
81
+ const pointer = rows.find((row) => row.head !== null);
82
+ const tier = Context.get(services, ColdTier);
83
+ const material = pointer?.cold_ref == null
84
+ ? undefined
85
+ : yield* Effect.suspend(() => tier === undefined
86
+ ? Effect.die(new Error("Cold query needs coldStorage"))
87
+ : tier
88
+ .fetch({ key, ref: request.ref }, {
89
+ ref: pointer.cold_ref,
90
+ digest: pointer.cold_digest,
91
+ version: pointer.cold_state_version,
92
+ })
93
+ .pipe(Effect.mapError((cause) => ActorError.make({ reason: ActorUnavailable.make({ cause }) }))));
94
+ const outcome = yield* query
95
+ .run(request, material?.envelope.state ?? state, cursor, (tag, after, limit) => replayEvents(request.ref, key, [tag], after, BigInt(cursor), limit).pipe(Effect.catchIf(SqlError.isSqlError, Effect.die), Effect.provideService(SqlClient.SqlClient, client), Effect.provideContext(services)), reads)
96
+ .pipe(Effect.provideService(ColdRead, material), withTenant(request.ref.tenant));
80
97
  if (Outcome.guards.Defect(outcome) && SqlError.isSqlError(outcome.cause))
81
98
  return yield* outcome.cause;
82
99
  return outcome;
83
- }).pipe(withTenant(request.ref.tenant), Effect.provideService(SqlClient.SqlClient, client));
100
+ }).pipe(Effect.provideService(SqlClient.SqlClient, client));
84
101
  const owned = registration.tables.length > 0 || registration.blobs.length > 0;
85
102
  const local = owned ? primary : (queryPool ?? primary);
86
103
  const outcome = yield* Effect.gen(function* () {
@@ -1,4 +1,5 @@
1
- import { Context, Crypto, Effect } from "effect";
1
+ import { PgliteClient } from "@effect/sql-pglite";
2
+ import { Context, Crypto, Effect, Option } from "effect";
2
3
  import { Sharding } from "effect/cluster";
3
4
  import { SqlClient, SqlError } from "effect/sql";
4
5
  import { isContent } from "../members/blob.js";
@@ -25,6 +26,29 @@ const ROUTED_SUBSCRIBER_WAIT_MS = 5000;
25
26
  export const actorRegistration = ({ registrations, queryRegistrations, jobRegistrations, residency, diagnostics, owners, heldTypes, heldType, sweepsWorkflows, checked, services, frameworkClock, content, retryWindowMs, tableRole, adoptionRole, enforcedTypes, writerDeclarations, refreshPayloadWriters, subscriptions, transport, authorize, gate, writable, defectLog, outbox, recorder, }) => {
26
27
  /** Concurrent layers can exceed checkout capacity; only a refused checkout is safe to retry here. */
27
28
  const startupSql = (effect) => retryPoolRefusal(effect).pipe(Effect.orDie);
29
+ /**
30
+ * A shortened migration history cannot reinterpret older cold objects.
31
+ * PGlite cannot offload, so it needs no transaction that would eagerly start
32
+ * its singleton actors during registration.
33
+ */
34
+ const checkColdState = Effect.fnUntraced(function* (registration) {
35
+ if (Option.isSome(yield* Effect.serviceOption(PgliteClient.PgliteClient)))
36
+ return;
37
+ const sql = yield* SqlClient.SqlClient;
38
+ yield* sql.withTransaction(Effect.gen(function* () {
39
+ const [record] = yield* sql `SELECT state_version FROM actor_placements
40
+ WHERE actor_type = ${registration.name} FOR UPDATE`;
41
+ const previous = record.state_version;
42
+ const unreadable = yield* sql `SELECT 1 FROM actor_generations
43
+ WHERE actor_type = ${registration.name} AND cold_ref IS NOT NULL
44
+ AND (cold_state_version > ${registration.stateVersion}
45
+ OR (${registration.stateVersion} < ${previous} AND cold_state_version < ${previous})) LIMIT 1`;
46
+ if (unreadable.length > 0)
47
+ return yield* Effect.die(new Error(`Actor ${registration.name} state chain is shortened while cold actors need it; deploy refused`));
48
+ yield* sql `UPDATE actor_placements SET state_version = ${registration.stateVersion}
49
+ WHERE actor_type = ${registration.name}`;
50
+ }));
51
+ });
28
52
  /**
29
53
  * Refuses a layer that can't read every payload version the database
30
54
  * may hold, as a placement or workflow mismatch is refused. `writes`
@@ -91,6 +115,7 @@ export const actorRegistration = ({ registrations, queryRegistrations, jobRegist
91
115
  if (registrations.has(registration.name))
92
116
  return yield* Effect.die(new Error(`Duplicate actor: ${registration.name}`));
93
117
  yield* checkPlacement(registration).pipe(Effect.provideContext(services), startupSql);
118
+ yield* checkColdState(registration).pipe(Effect.provideContext(services), startupSql);
94
119
  if (declaresContent(registration)) {
95
120
  yield* requireContent(registration.name);
96
121
  yield* recordContentTurn(registration).pipe(Effect.provideContext(services), startupSql);
@@ -159,6 +184,7 @@ export const actorRegistration = ({ registrations, queryRegistrations, jobRegist
159
184
  if (queryRegistrations.has(registration.name))
160
185
  return yield* Effect.die(new Error(`Duplicate query layer: ${registration.name}`));
161
186
  yield* checkPlacement(registration).pipe(Effect.provideContext(services), startupSql);
187
+ yield* checkColdState(registration).pipe(Effect.provideContext(services), startupSql);
162
188
  if (declaresContent(registration))
163
189
  yield* requireContent(registration.name);
164
190
  yield* checkTables(registration.name, registration.tables, tableRole, {
@@ -86,7 +86,7 @@ export declare const Request: Schema.Struct<{
86
86
  readonly caller: Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"User", {
87
87
  readonly subject: Schema.NonEmptyString;
88
88
  }>, Schema.TaggedStruct<"Anonymous", {}>, Schema.TaggedStruct<"System", {
89
- readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
89
+ readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
90
90
  readonly ref: Schema.optional<Schema.Struct<{
91
91
  readonly tenant: Schema.NonEmptyString;
92
92
  readonly actor: Schema.NonEmptyString;
@@ -71,9 +71,24 @@ export interface SocketRunnerOptions<E> {
71
71
  readonly shardLockRefreshInterval?: Duration.Input;
72
72
  /** Default 1 second. */
73
73
  readonly refreshAssignmentsInterval?: Duration.Input;
74
- /** Default 15 seconds, bounding activation shutdown during a shard handoff. */
74
+ /**
75
+ * Default 15 seconds, bounding activation shutdown during a shard handoff.
76
+ * Must not exceed shardLockExpiration minus the smaller of
77
+ * shardLockRefreshInterval and one third of shardLockExpiration.
78
+ */
75
79
  readonly entityTerminationTimeout?: Duration.Input;
76
80
  }
81
+ declare const RunnerConfigurationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
82
+ readonly _tag: "RunnerConfigurationError";
83
+ } & Readonly<A>;
84
+ /** Synchronous configuration refusal before runner startup when shutdown can outlast its shard lock. */
85
+ export declare class RunnerConfigurationError extends RunnerConfigurationError_base<{
86
+ readonly entityTerminationTimeoutMs: number;
87
+ readonly shardLockExpirationMs: number;
88
+ readonly effectiveRefreshIntervalMs: number;
89
+ }> {
90
+ get message(): string;
91
+ }
77
92
  /**
78
93
  * Joins separate processes over TCP sockets. Provide this layer to
79
94
  * `Actors.layer`; the framework keeps direct commands, SQL ownership, receipts,
@@ -82,6 +97,8 @@ export interface SocketRunnerOptions<E> {
82
97
  * transport unless the network is isolated to this deployment's runners. The
83
98
  * transport's own services stay in the runtime, so readiness sees whether a
84
99
  * mutual TLS transport can still peer.
100
+ * Throws RunnerConfigurationError synchronously if activation shutdown can
101
+ * outlast the shard lock's remaining lifetime after its effective refresh.
85
102
  */
86
103
  export declare const socket: <E>(options: SocketRunnerOptions<E>) => Layer.Layer<RunnerWiring, never, never>;
87
104
  /**
@@ -1,4 +1,4 @@
1
- import { Context, Duration, Effect, HashRing, Layer, Option, PrimaryKey } from "effect";
1
+ import { Context, Data, Duration, Effect, HashRing, Layer, Option, PrimaryKey } from "effect";
2
2
  import { MessageStorage, RunnerAddress, RunnerHealth, Runners, RunnerServer, RunnerStorage, Sharding, ShardingConfig, ShardId, } from "effect/cluster";
3
3
  import { RpcSerialization, RpcServer } from "effect/rpc";
4
4
  import { SocketServer } from "effect/socket";
@@ -60,6 +60,12 @@ export const acquiredShards = Effect.gen(function* () {
60
60
  }).pipe(Effect.timeoutOption("2 seconds"), Effect.map(Option.getOrElse(() => false)), Effect.orElseSucceed(() => false)),
61
61
  });
62
62
  });
63
+ /** Synchronous configuration refusal before runner startup when shutdown can outlast its shard lock. */
64
+ export class RunnerConfigurationError extends Data.TaggedError("RunnerConfigurationError") {
65
+ get message() {
66
+ return `entityTerminationTimeout (${this.entityTerminationTimeoutMs} ms) must not exceed shardLockExpiration minus the effective shardLockRefreshInterval (${this.shardLockExpirationMs - this.effectiveRefreshIntervalMs} ms)`;
67
+ }
68
+ }
63
69
  const address = (value, advertise) => {
64
70
  if (value.host.trim().length === 0 ||
65
71
  (advertise && ["0.0.0.0", "::", "*"].includes(value.host)) ||
@@ -83,21 +89,35 @@ const duration = (value, name, minimum = 1) => {
83
89
  * transport unless the network is isolated to this deployment's runners. The
84
90
  * transport's own services stay in the runtime, so readiness sees whether a
85
91
  * mutual TLS transport can still peer.
92
+ * Throws RunnerConfigurationError synchronously if activation shutdown can
93
+ * outlast the shard lock's remaining lifetime after its effective refresh.
86
94
  */
87
95
  export const socket = (options) => {
88
96
  const shardsPerGroup = options.shardsPerGroup ?? 256;
89
97
  if (!Number.isSafeInteger(shardsPerGroup) || shardsPerGroup < 1 || shardsPerGroup > 65536)
90
98
  throw new Error("shardsPerGroup must be an integer between 1 and 65536");
99
+ const shardLockExpiration = duration(options.shardLockExpiration ?? "35 seconds", "shardLockExpiration", 3000);
100
+ const shardLockRefreshInterval = duration(options.shardLockRefreshInterval ?? "10 seconds", "shardLockRefreshInterval");
101
+ const entityTerminationTimeout = duration(options.entityTerminationTimeout ?? "15 seconds", "entityTerminationTimeout");
102
+ const shardLockExpirationMs = Duration.toMillis(shardLockExpiration);
103
+ const effectiveRefreshIntervalMs = Math.min(Duration.toMillis(shardLockRefreshInterval), shardLockExpirationMs / 3);
104
+ const entityTerminationTimeoutMs = Duration.toMillis(entityTerminationTimeout);
105
+ if (entityTerminationTimeoutMs > shardLockExpirationMs - effectiveRefreshIntervalMs)
106
+ throw new RunnerConfigurationError({
107
+ entityTerminationTimeoutMs,
108
+ shardLockExpirationMs,
109
+ effectiveRefreshIntervalMs,
110
+ });
91
111
  const config = {
92
112
  runnerAddress: Option.some(address(options.address, true)),
93
113
  runnerListenAddress: Option.some(address(options.listenAddress ?? options.address, false)),
94
114
  shardsPerGroup,
95
115
  shardLockDisableAdvisory: true,
96
- shardLockExpiration: duration(options.shardLockExpiration ?? "35 seconds", "shardLockExpiration", 3000),
97
- shardLockRefreshInterval: duration(options.shardLockRefreshInterval ?? "10 seconds", "shardLockRefreshInterval"),
116
+ shardLockExpiration,
117
+ shardLockRefreshInterval,
98
118
  refreshAssignmentsInterval: duration(options.refreshAssignmentsInterval ?? "1 second", "refreshAssignmentsInterval"),
99
119
  entityMessagePollInterval: "1 second",
100
- entityTerminationTimeout: duration(options.entityTerminationTimeout ?? "15 seconds", "entityTerminationTimeout"),
120
+ entityTerminationTimeout,
101
121
  };
102
122
  const sharding = RunnerServer.layer.pipe(Layer.provideMerge(admissionSharding), Layer.provideMerge(Runners.layerRpc), Layer.provide(RpcServer.layerProtocolSocketServer), Layer.provideMerge(options.transport), Layer.provide(RpcSerialization.layerNdjson), Layer.orDie);
103
123
  return Layer.succeed(RunnerWiring, {
@@ -0,0 +1,56 @@
1
+ import { type S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import { Effect, FileSystem, Path, Stream } from "effect";
3
+ declare const ColdStorageError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
4
+ readonly _tag: "ColdStorageError";
5
+ } & Readonly<A>;
6
+ /** A failed object operation, including an unknown upload or deletion outcome. */
7
+ export declare class ColdStorageError extends ColdStorageError_base<{
8
+ readonly cause: unknown;
9
+ }> {
10
+ }
11
+ /** An immutable object's key and creation time, used only as an orphan retention bound. */
12
+ export interface ColdObject {
13
+ readonly key: string;
14
+ readonly createdAtMs: number;
15
+ }
16
+ /**
17
+ * Private deployment object storage. `put` creates a key once and returns
18
+ * false if it already exists; a retry must verify the existing bytes.
19
+ * `delete` succeeds only when absence is confirmed. Listing is paginated,
20
+ * and failures never mean an empty object or a successful deletion.
21
+ */
22
+ export interface ColdStorage {
23
+ readonly get: (key: string) => Effect.Effect<Uint8Array, ColdStorageError>;
24
+ readonly put: (key: string, bytes: Uint8Array) => Effect.Effect<boolean, ColdStorageError>;
25
+ readonly delete: (key: string) => Effect.Effect<void, ColdStorageError>;
26
+ readonly list: (prefix: string) => Stream.Stream<ColdObject, ColdStorageError>;
27
+ }
28
+ /** A deterministic create-only store for local tests; copies bytes at both boundaries. */
29
+ declare const memory: () => ColdStorage;
30
+ /** S3-compatible credentials, endpoint, bucket, and deployment encryption settings. */
31
+ export interface S3ColdStorageOptions extends S3ClientConfig {
32
+ readonly bucket: string;
33
+ /** Omitted, use the bucket's configured encryption. */
34
+ readonly encryption?: "AES256" | "aws:kms";
35
+ readonly encryptionKey?: string;
36
+ }
37
+ /** Object-storage adapters for runtime-only cold state, never tenant content. */
38
+ export declare const ColdStorage: {
39
+ memory: typeof memory;
40
+ filesystem: (directory: string) => Effect.Effect<{
41
+ get: (key: string) => Effect.Effect<Uint8Array<ArrayBufferLike>, ColdStorageError, never>;
42
+ put: (key: string, bytes: Uint8Array<ArrayBufferLike>) => Effect.Effect<boolean, ColdStorageError, never>;
43
+ delete: (key: string) => Effect.Effect<void, ColdStorageError, never>;
44
+ list: (prefix: string) => Stream.Stream<ColdObject, ColdStorageError, never>;
45
+ }, never, FileSystem.FileSystem | Path.Path>;
46
+ s3: (args_0: S3ColdStorageOptions) => Effect.Effect<{
47
+ get: (key: string) => Effect.Effect<Uint8Array<ArrayBufferLike>, ColdStorageError, never>;
48
+ put: (key: string, bytes: Uint8Array<ArrayBufferLike>) => Effect.Effect<boolean, ColdStorageError, never>;
49
+ delete: (key: string) => Effect.Effect<void, ColdStorageError, never>;
50
+ list: (prefix: string) => Stream.Stream<{
51
+ key: string;
52
+ createdAtMs: number;
53
+ }, ColdStorageError, never>;
54
+ }, never, import("effect/Scope").Scope>;
55
+ };
56
+ export {};