@rikalabs/akter 0.1.0-alpha.5 → 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.
- package/CHANGELOG.md +16 -0
- package/dist/actor/descriptor.js +3 -0
- package/dist/actor/handles.d.ts +1 -1
- package/dist/actor/layers.js +2 -0
- package/dist/actor/reads.js +2 -1
- package/dist/contexts/command.d.ts +2 -0
- package/dist/identity/caller.d.ts +4 -4
- package/dist/identity/caller.js +11 -2
- package/dist/identity/mint.d.ts +1 -1
- package/dist/policies/command.d.ts +3 -0
- package/dist/policies/command.js +1 -0
- package/dist/runtime/connections/owner.js +60 -13
- package/dist/runtime/connections/protocol.d.ts +2 -2
- package/dist/runtime/connections/streams.js +2 -1
- package/dist/runtime/database/bounded.js +17 -2
- package/dist/runtime/database/connection.d.ts +3 -2
- package/dist/runtime/database/connection.js +9 -4
- package/dist/runtime/database/coordination.d.ts +6 -3
- package/dist/runtime/database/coordination.js +19 -18
- package/dist/runtime/database/migrations.d.ts +4 -0
- package/dist/runtime/database/migrations.js +72 -1
- package/dist/runtime/entity/mailbox.d.ts +18 -10
- package/dist/runtime/entity/mailbox.js +42 -34
- package/dist/runtime/entity/register.d.ts +3 -3
- package/dist/runtime/entity/register.js +280 -271
- package/dist/runtime/events/append.d.ts +3 -3
- package/dist/runtime/index.d.ts +4 -1
- package/dist/runtime/index.js +2 -1
- package/dist/runtime/layer.d.ts +15 -1
- package/dist/runtime/layer.js +44 -9
- package/dist/runtime/members.d.ts +2 -0
- package/dist/runtime/operators/export.d.ts +3 -1
- package/dist/runtime/operators/export.js +42 -11
- package/dist/runtime/queries.d.ts +1 -1
- package/dist/runtime/queries.js +23 -6
- package/dist/runtime/registration.js +27 -1
- package/dist/runtime/request.d.ts +1 -1
- package/dist/runtime/runner.d.ts +18 -1
- package/dist/runtime/runner.js +24 -4
- package/dist/runtime/storage/cold-storage.d.ts +56 -0
- package/dist/runtime/storage/cold-storage.js +138 -0
- package/dist/runtime/storage/cold-tier.d.ts +141 -0
- package/dist/runtime/storage/cold-tier.js +355 -0
- package/dist/runtime/storage/generation.d.ts +14 -0
- package/dist/runtime/storage/generation.js +2 -0
- package/dist/runtime/storage/placements.js +1 -0
- package/dist/runtime/telemetry/metrics.d.ts +4 -0
- package/dist/runtime/telemetry/metrics.js +20 -4
- package/dist/runtime/telemetry/sampler.js +4 -3
- package/dist/runtime/telemetry/spans.d.ts +1 -1
- package/dist/runtime/topology/locks.js +8 -6
- package/dist/runtime/turn/blobs.d.ts +4 -1
- package/dist/runtime/turn/blobs.js +19 -2
- package/dist/runtime/turn/execute.d.ts +3 -1
- package/dist/runtime/turn/execute.js +272 -20
- package/dist/runtime/turn/hooks.d.ts +1 -1
- package/dist/runtime/turn/outbox.d.ts +1 -1
- package/dist/runtime/turn/receipt.d.ts +1 -1
- package/dist/runtime/turn/relay.d.ts +2 -2
- package/dist/runtime/turn/relay.js +53 -7
- package/dist/testing/actor-test.d.ts +1 -0
- package/dist/testing/actor-test.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.1.0-alpha.5 (2026-10-09)
|
|
4
20
|
|
|
5
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.
|
package/dist/actor/descriptor.js
CHANGED
|
@@ -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");
|
package/dist/actor/handles.d.ts
CHANGED
|
@@ -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;
|
package/dist/actor/layers.js
CHANGED
|
@@ -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,
|
package/dist/actor/reads.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/identity/caller.js
CHANGED
|
@@ -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
|
|
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([
|
|
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),
|
package/dist/identity/mint.d.ts
CHANGED
|
@@ -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;
|
package/dist/policies/command.js
CHANGED
|
@@ -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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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) =>
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
11
|
-
export declare const withMigrationCoordination: <A, E>(effect
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
})
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|