@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.
- package/CHANGELOG.md +24 -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 +24 -1
- package/dist/runtime/layer.js +64 -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Cause, Clock, Crypto, Effect, Exit, Option, Predicate, Result, Schema, Scope, } from "effect";
|
|
1
|
+
import { Cause, Clock, Context, Crypto, Effect, Exit, Option, Predicate, Result, Schema, Scope, } from "effect";
|
|
2
2
|
import { SqlClient, SqlError } from "effect/sql";
|
|
3
3
|
import { ActorError, CommandExpired, NotCreated, Unauthorized } from "../../errors/actor.js";
|
|
4
4
|
import {} from "../members.js";
|
|
@@ -12,7 +12,8 @@ import { eventsStatement, notifyEvents } from "../events/append.js";
|
|
|
12
12
|
import { COMMIT_VERSION } from "../database/replica.js";
|
|
13
13
|
import { isPoolRefusal } from "../database/bounded.js";
|
|
14
14
|
import { compress, decompress } from "../storage/codec.js";
|
|
15
|
-
import {
|
|
15
|
+
import { ColdTier, FetchCold } from "../storage/cold-tier.js";
|
|
16
|
+
import { count, Metrics, record } from "../telemetry/metrics.js";
|
|
16
17
|
import { SpanNames } from "../telemetry/spans.js";
|
|
17
18
|
import { receiptMarginMs } from "../storage/retention.js";
|
|
18
19
|
import { hashedPayload } from "../subscriptions/identity.js";
|
|
@@ -26,6 +27,10 @@ import { asSqlConnection, awaitReplies, queueStatements, sendPipelined, sendSequ
|
|
|
26
27
|
import { MERGE_CAP, merges } from "../entity/mailbox.js";
|
|
27
28
|
import { checkReceipt, encodeOutcome, hashCanonical } from "./receipt.js";
|
|
28
29
|
const isSystem = Schema.is(System);
|
|
30
|
+
/** Tests can exercise the ordinary admission pipeline independently of warm speculation. */
|
|
31
|
+
export const WarmTurnFastPath = Context.Reference("akter/WarmTurnFastPath", {
|
|
32
|
+
defaultValue: () => true,
|
|
33
|
+
});
|
|
29
34
|
/**
|
|
30
35
|
* The cursor row once `delivery` applied, as its commit writes it: a routed
|
|
31
36
|
* delivery creates the epoch-0 row or raises its position, a dynamic one moves
|
|
@@ -210,9 +215,12 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
210
215
|
const sql = yield* SqlClient.SqlClient;
|
|
211
216
|
const hooks = yield* TurnHooks;
|
|
212
217
|
const clock = yield* FrameworkClock;
|
|
218
|
+
const tier = yield* ColdTier;
|
|
213
219
|
const scope = yield* TenantScope;
|
|
220
|
+
const warmTurns = yield* WarmTurnFastPath;
|
|
214
221
|
const { ref } = run.first[0].request;
|
|
215
222
|
const { tenant, actor, id } = ref;
|
|
223
|
+
let coldStartedAtMs;
|
|
216
224
|
const role = scope.role ?? (scope.adoption?.enforced.has(actor) === true ? scope.adoption.role : undefined);
|
|
217
225
|
const actorRow = rowOf({ sql, actor: { key: routingKey, ref } });
|
|
218
226
|
const canonicalsOf = (batch) => Effect.forEach(batch, ({ request }) => hashedPayload(request));
|
|
@@ -239,10 +247,10 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
239
247
|
role === undefined ? "-" : `+${encodeURIComponent(role)}`,
|
|
240
248
|
role === undefined ? "-" : `+${encodeURIComponent(tenant)}`,
|
|
241
249
|
].join(":");
|
|
242
|
-
const admit = (batch, canonicals, view, session, begin, grouped = false) => {
|
|
250
|
+
const admit = (batch, canonicals, view, session, begin, grouped = false, optimistic) => {
|
|
243
251
|
const cold = view.generation === undefined;
|
|
244
252
|
const readsState = cold || view.state === undefined;
|
|
245
|
-
let admissions = [];
|
|
253
|
+
let admissions = optimistic === undefined ? [] : [optimistic];
|
|
246
254
|
let bumped;
|
|
247
255
|
let stored = [];
|
|
248
256
|
const subscribed = batch.some(({ request }) => request.delivery !== undefined);
|
|
@@ -278,7 +286,8 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
278
286
|
Effect.map(sql `
|
|
279
287
|
WITH locked AS MATERIALIZED (
|
|
280
288
|
SELECT g.routing_key, g.tenant_id, g.actor_type, g.actor_id,
|
|
281
|
-
g.generation, g.created, g.event_sequence
|
|
289
|
+
g.generation, g.created, g.event_sequence,
|
|
290
|
+
g.cold_ref, g.cold_digest, g.cold_state_version
|
|
282
291
|
FROM actor_generations g
|
|
283
292
|
WHERE ${rowOf({ sql, actor: { key: routingKey, ref }, alias: "g" })}
|
|
284
293
|
FOR UPDATE OF g ${grouped ? sql.literal("SKIP LOCKED") : sql.literal("")}
|
|
@@ -286,7 +295,8 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
286
295
|
SELECT floor(extract(epoch FROM clock_timestamp()) * 1000)::text AS now,
|
|
287
296
|
g.generation::text AS generation, g.created,
|
|
288
297
|
c.payload::jsonb::text AS canonical,
|
|
289
|
-
r.caller_key, r.command, r.payload_hash, r.outcome, g.event_sequence::text AS head
|
|
298
|
+
r.caller_key, r.command, r.payload_hash, r.outcome, g.event_sequence::text AS head,
|
|
299
|
+
g.cold_ref, g.cold_digest, g.cold_state_version
|
|
290
300
|
${cursorColumns}
|
|
291
301
|
FROM locked g
|
|
292
302
|
CROSS JOIN ${values}
|
|
@@ -303,6 +313,7 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
303
313
|
RETURNING generation::text AS generation`, (rows) => {
|
|
304
314
|
bumped = rows[0]?.generation;
|
|
305
315
|
}),
|
|
316
|
+
Effect.asVoid(sql `DELETE FROM actor_outbox WHERE ${actorRow} AND timer_key = '$cold'`),
|
|
306
317
|
]
|
|
307
318
|
: []),
|
|
308
319
|
...(readsState
|
|
@@ -325,6 +336,9 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
325
336
|
}
|
|
326
337
|
const current = cold ? bumped : first.generation;
|
|
327
338
|
const now = Number(first.now) + clock.offsetMillis();
|
|
339
|
+
if (first.cold_ref === null)
|
|
340
|
+
cache.cold = undefined;
|
|
341
|
+
let restored = false;
|
|
328
342
|
const { retryWindowMs } = yield* OutboxRuntime;
|
|
329
343
|
const expiryMarginMs = receiptMarginMs({
|
|
330
344
|
keepReceiptsMs: policy.keepReceiptsMs,
|
|
@@ -342,6 +356,7 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
342
356
|
let creates = false;
|
|
343
357
|
let replayed = false;
|
|
344
358
|
let wake = false;
|
|
359
|
+
let needsAdmissionClock = false;
|
|
345
360
|
const broadcasts = [];
|
|
346
361
|
let events = 0;
|
|
347
362
|
let intents = 0;
|
|
@@ -430,6 +445,8 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
430
445
|
: Effect.map(committedMintIntent(request, parent), (committed) => committed ? hash : refuse());
|
|
431
446
|
};
|
|
432
447
|
const admitOne = (index) => {
|
|
448
|
+
if (optimistic !== undefined)
|
|
449
|
+
return admitHashed(index, "");
|
|
433
450
|
const request = batch[index].request;
|
|
434
451
|
const identityNow = Number(admissions[index].now) + (request.clockOffset ?? clock.offsetMillis());
|
|
435
452
|
const hashed = Effect.flatMap(hashCanonical(admissions[index].canonical), (hash) => admitHashed(index, hash));
|
|
@@ -453,6 +470,19 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
453
470
|
index += 1;
|
|
454
471
|
continue;
|
|
455
472
|
}
|
|
473
|
+
if (first.cold_ref !== null)
|
|
474
|
+
coldStartedAtMs ??= Number(first.now);
|
|
475
|
+
if (first.cold_ref !== null &&
|
|
476
|
+
(cache.cold?.ref !== first.cold_ref ||
|
|
477
|
+
cache.cold.digest !== first.cold_digest ||
|
|
478
|
+
cache.cold.version !== first.cold_state_version))
|
|
479
|
+
return yield* Effect.die(new FetchCold({
|
|
480
|
+
pointer: {
|
|
481
|
+
ref: first.cold_ref,
|
|
482
|
+
digest: first.cold_digest,
|
|
483
|
+
version: first.cold_state_version,
|
|
484
|
+
},
|
|
485
|
+
}));
|
|
456
486
|
const start = index;
|
|
457
487
|
const members = [{ index, request, hash }];
|
|
458
488
|
index += 1;
|
|
@@ -465,23 +495,35 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
465
495
|
index += 1;
|
|
466
496
|
}
|
|
467
497
|
next ??= readsState
|
|
468
|
-
? new Map(
|
|
498
|
+
? new Map(first.cold_ref === null
|
|
499
|
+
? stored.map(({ key, value }) => [key, decompress(value)])
|
|
500
|
+
: cache.cold.envelope.state)
|
|
469
501
|
: new Map(view.state);
|
|
470
502
|
const given = next;
|
|
471
503
|
const head = String(BigInt(first.head) + BigInt(events));
|
|
472
504
|
const savepoint = `SAVEPOINT ${HANDLER_SAVEPOINT}`;
|
|
473
|
-
|
|
505
|
+
const restoring = first.cold_ref !== null && !restored;
|
|
506
|
+
const savepoints = statements || restoring;
|
|
507
|
+
if (savepoints && (start > 0 || !statements))
|
|
474
508
|
yield* session.defer(savepoint);
|
|
509
|
+
if (restoring) {
|
|
510
|
+
if (tier === undefined)
|
|
511
|
+
return yield* Effect.die(new Error("Cold actor needs coldStorage"));
|
|
512
|
+
yield* tier.restore({ key: routingKey, ref }, cache.cold);
|
|
513
|
+
yield* hooks.at("afterColdWriteBack", request);
|
|
514
|
+
}
|
|
475
515
|
const business = yield* hooks.at("beforeHandler", request).pipe(Effect.andThen(() => members.length === 1
|
|
476
516
|
? command.run(request, [...given], { head, connections })
|
|
477
517
|
: command.merge(members.map((member) => member.request), [...given])), Effect.catchIf(SqlError.isSqlError, Effect.die), Effect.result);
|
|
478
518
|
const result = Result.isSuccess(business)
|
|
479
519
|
? business.success
|
|
480
520
|
: business.failure;
|
|
481
|
-
if (statements &&
|
|
521
|
+
if (savepoints && ((statements && start === 0) || !session.withdraw(savepoint)))
|
|
482
522
|
yield* session.defer(Result.isSuccess(business)
|
|
483
523
|
? `RELEASE SAVEPOINT ${HANDLER_SAVEPOINT}`
|
|
484
524
|
: `ROLLBACK TO SAVEPOINT ${HANDLER_SAVEPOINT}`);
|
|
525
|
+
if (restoring && Result.isSuccess(business))
|
|
526
|
+
restored = true;
|
|
485
527
|
const written = new Map(result.state);
|
|
486
528
|
if (result.complete)
|
|
487
529
|
for (const key of given.keys())
|
|
@@ -514,6 +556,10 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
514
556
|
blobs.add(blob);
|
|
515
557
|
intents += result.outbox.intents.length;
|
|
516
558
|
jobs += result.outbox.jobs.length;
|
|
559
|
+
needsAdmissionClock ||=
|
|
560
|
+
result.outbox.intents.length > 0 ||
|
|
561
|
+
result.outbox.jobs.length > 0 ||
|
|
562
|
+
result.outbox.subscriptions.length > 0;
|
|
517
563
|
if (Outcome.guards.Success(result.outcome))
|
|
518
564
|
broadcasts.push(...(result.broadcasts ?? []));
|
|
519
565
|
if (yield* notifyEvents(request, routingKey, result.events, waited))
|
|
@@ -563,6 +609,8 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
563
609
|
settled,
|
|
564
610
|
generation: current,
|
|
565
611
|
state: view.state,
|
|
612
|
+
created,
|
|
613
|
+
needsAdmissionClock,
|
|
566
614
|
wake: false,
|
|
567
615
|
broadcasts: [],
|
|
568
616
|
head: first.head,
|
|
@@ -591,7 +639,15 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
591
639
|
if (creates)
|
|
592
640
|
writes.push(Effect.asVoid(sql `UPDATE actor_generations SET created = true WHERE ${actorRow}`));
|
|
593
641
|
if (receipts.length > 0) {
|
|
594
|
-
|
|
642
|
+
const receipt = receipts[0];
|
|
643
|
+
writes.push(Effect.asVoid(optimistic === undefined
|
|
644
|
+
? sql `INSERT INTO actor_receipts ${sql.insert(receipts)}`
|
|
645
|
+
: sql `INSERT INTO actor_receipts (routing_key, tenant_id, actor_type, actor_id,
|
|
646
|
+
command_id, command, payload_hash, caller_key, outcome, expires_at_ms, started_at_ms)
|
|
647
|
+
VALUES (${routingKey}, ${tenant}, ${actor}, ${id}, ${receipt.command_id},
|
|
648
|
+
${receipt.command}, encode(sha256(convert_to(${batch[0].request.payload}::jsonb::text, 'UTF8')), 'hex'),
|
|
649
|
+
${receipt.caller_key}, ${receipt.outcome}, ${receipt.expires_at_ms},
|
|
650
|
+
current_setting('durable.admitted_at_ms')::bigint)`));
|
|
595
651
|
if (accounting !== undefined)
|
|
596
652
|
writes.push(accounting.commands({
|
|
597
653
|
ref,
|
|
@@ -602,9 +658,12 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
602
658
|
writes.push(...(yield* ticks));
|
|
603
659
|
return {
|
|
604
660
|
writes,
|
|
661
|
+
restored,
|
|
605
662
|
settled,
|
|
606
663
|
generation: current,
|
|
607
|
-
state: next,
|
|
664
|
+
state: first.cold_ref !== null && !restored ? undefined : next,
|
|
665
|
+
created,
|
|
666
|
+
needsAdmissionClock,
|
|
608
667
|
wake,
|
|
609
668
|
broadcasts,
|
|
610
669
|
head: String(BigInt(first.head) + BigInt(events)),
|
|
@@ -645,9 +704,40 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
645
704
|
if (plan.writes !== undefined) {
|
|
646
705
|
cache.generation = plan.generation;
|
|
647
706
|
cache.state = plan.state;
|
|
707
|
+
if (plan.restored === true)
|
|
708
|
+
cache.cold = undefined;
|
|
709
|
+
if (plan.state !== undefined) {
|
|
710
|
+
const previous = cache.committed;
|
|
711
|
+
const receipts = previous?.generation === plan.generation ? previous.receipts : new Set();
|
|
712
|
+
let replayBefore = previous?.generation === plan.generation
|
|
713
|
+
? previous.replayBefore
|
|
714
|
+
: plan.startedAtMs + clock.offsetMillis();
|
|
715
|
+
for (const [index, settled] of plan.settled.entries())
|
|
716
|
+
if (Result.isSuccess(settled))
|
|
717
|
+
receipts.add(batch[index].request.commandId);
|
|
718
|
+
while (receipts.size > 1_024) {
|
|
719
|
+
const oldest = receipts.values().next().value;
|
|
720
|
+
replayBefore = Math.max(replayBefore, commandTimes(oldest).issuedAt);
|
|
721
|
+
receipts.delete(oldest);
|
|
722
|
+
}
|
|
723
|
+
cache.committed = {
|
|
724
|
+
generation: plan.generation,
|
|
725
|
+
state: plan.state,
|
|
726
|
+
created: plan.created,
|
|
727
|
+
head: plan.head,
|
|
728
|
+
now: endedAtMs,
|
|
729
|
+
replayBefore,
|
|
730
|
+
receipts,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
else
|
|
734
|
+
cache.committed = undefined;
|
|
648
735
|
}
|
|
736
|
+
const coldStarted = coldStartedAtMs;
|
|
737
|
+
coldStartedAtMs = undefined;
|
|
649
738
|
answering = true;
|
|
650
|
-
return run
|
|
739
|
+
return run
|
|
740
|
+
.committed(batch, {
|
|
651
741
|
settled: plan.settled,
|
|
652
742
|
broadcasts: plan.broadcasts,
|
|
653
743
|
head: plan.head,
|
|
@@ -667,7 +757,15 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
667
757
|
plan.outbox.some((replies) => replies.wake) ||
|
|
668
758
|
plan.emitted.some((stamp) => stamp.fed),
|
|
669
759
|
cancelled: plan.outbox.some((replies) => replies.cancelled),
|
|
670
|
-
})
|
|
760
|
+
})
|
|
761
|
+
.pipe(Effect.tap(() => Effect.all([
|
|
762
|
+
plan.restored === true
|
|
763
|
+
? count(Metrics.coldRestorations, { actor_type: actor }, 1)
|
|
764
|
+
: Effect.void,
|
|
765
|
+
coldStarted === undefined
|
|
766
|
+
? Effect.void
|
|
767
|
+
: record(Metrics.coldWakeDuration, { actor_type: actor }, endedAtMs - coldStarted),
|
|
768
|
+
], { discard: true })));
|
|
671
769
|
}).pipe(Effect.andThen(Effect.sync(() => {
|
|
672
770
|
answering = false;
|
|
673
771
|
})));
|
|
@@ -810,8 +908,118 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
810
908
|
}));
|
|
811
909
|
});
|
|
812
910
|
};
|
|
813
|
-
const alone = (batch, { admission, admitted }, taken) => awaitReplies(admitted).pipe(Effect.andThen(() => admission.resume()), Effect.flatMap((plan) => Effect.flatMap(taken === undefined ? run.next : Effect.succeed(taken), (following) => commitPlan(batch, plan, following))), inTurn, bounded, Effect.filterOrElse((stepped) => stepped.ending !== "COMMIT" || stepped.tag === "COMMIT", () => Effect.die(RetryTurn.make({ message: "Turn commit rolled back" }))), Effect.tap((stepped) => (stepped.chained === undefined ? release : Effect.void)))
|
|
911
|
+
const alone = (batch, { admission, admitted }, taken) => awaitReplies(admitted).pipe(Effect.andThen(() => admission.resume()), Effect.flatMap((plan) => Effect.flatMap(taken === undefined ? run.next : Effect.succeed(taken), (following) => commitPlan(batch, plan, following))), inTurn, bounded, Effect.filterOrElse((stepped) => stepped.ending !== "COMMIT" || stepped.tag === "COMMIT", () => Effect.die(RetryTurn.make({ message: "Turn commit rolled back" }))), Effect.tap((stepped) => (stepped.chained === undefined ? release : Effect.void)), Effect.catchCause((cause) => {
|
|
912
|
+
const fetching = Cause.squash(cause);
|
|
913
|
+
if (!(fetching instanceof FetchCold))
|
|
914
|
+
return Effect.failCause(cause);
|
|
915
|
+
return Effect.gen(function* () {
|
|
916
|
+
deferred.length = 0;
|
|
917
|
+
const rollback = yield* control("ROLLBACK");
|
|
918
|
+
if (rollback.command !== "ROLLBACK")
|
|
919
|
+
return yield* Effect.die(RetryTurn.make({ message: "Cold admission rollback unconfirmed" }));
|
|
920
|
+
open = false;
|
|
921
|
+
yield* release;
|
|
922
|
+
yield* hooks.at("afterColdRollback", batch[0].request);
|
|
923
|
+
if (tier === undefined)
|
|
924
|
+
return yield* Effect.die(new Error("Cold actor needs coldStorage"));
|
|
925
|
+
cache.state = undefined;
|
|
926
|
+
cache.committed = undefined;
|
|
927
|
+
cache.cold = yield* tier
|
|
928
|
+
.fetch({ key: routingKey, ref }, fetching.pointer)
|
|
929
|
+
.pipe(Effect.catch((error) => Effect.die(RetryTurn.make({ message: error.message }))));
|
|
930
|
+
yield* hooks.at("afterColdFetch", batch[0].request);
|
|
931
|
+
return yield* alone(batch, yield* openAlone(batch), taken);
|
|
932
|
+
});
|
|
933
|
+
}));
|
|
814
934
|
const openAlone = (batch) => leased.pipe(Effect.andThen(queue(batch, view(), []).pipe(inTurn)), Effect.map(({ admission, flight }) => ({ admission, admitted: flight })));
|
|
935
|
+
/**
|
|
936
|
+
* A speculative plan uses only an already committed snapshot. The
|
|
937
|
+
* guard's deliberate division by zero aborts every pipelined write on
|
|
938
|
+
* a miss; the receipt's unique constraint also catches a commit that
|
|
939
|
+
* became visible after the guard's statement snapshot. Recovery then
|
|
940
|
+
* uses ordinary admission, never trusting an unconfirmed outcome.
|
|
941
|
+
*/
|
|
942
|
+
const optimistic = (batch, admitting, committed) => {
|
|
943
|
+
const { request } = batch[0];
|
|
944
|
+
const { issuedAt, expiresAt } = commandTimes(request.commandId);
|
|
945
|
+
const identityOffset = request.clockOffset ?? clock.offsetMillis();
|
|
946
|
+
const ordinary = () => Effect.flatMap(openAlone(batch), (opened) => alone(batch, opened));
|
|
947
|
+
return admitting.admission.resume().pipe(Effect.catchDefect((defect) => Schema.is(RetryTurn)(defect) || SqlError.isSqlError(defect)
|
|
948
|
+
? Effect.die(defect)
|
|
949
|
+
: Effect.void), Effect.flatMap((plan) => {
|
|
950
|
+
if (plan === undefined || plan.writes === undefined || plan.needsAdmissionClock)
|
|
951
|
+
return ordinary();
|
|
952
|
+
let missed = false;
|
|
953
|
+
let tag = "";
|
|
954
|
+
let version = "";
|
|
955
|
+
let endedAtMs = 0;
|
|
956
|
+
let startedAtMs = 0;
|
|
957
|
+
const guard = sql `
|
|
958
|
+
WITH locked AS MATERIALIZED (
|
|
959
|
+
SELECT g.generation, g.created, g.event_sequence,
|
|
960
|
+
to_jsonb(g)->>'cold_ref' AS cold_ref
|
|
961
|
+
FROM actor_generations g
|
|
962
|
+
WHERE ${rowOf({ sql, actor: { key: routingKey, ref }, alias: "g" })}
|
|
963
|
+
FOR UPDATE OF g
|
|
964
|
+
), checked AS MATERIALIZED (
|
|
965
|
+
SELECT *, floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint AS now
|
|
966
|
+
FROM locked
|
|
967
|
+
)
|
|
968
|
+
SELECT 1 / CASE WHEN EXISTS (
|
|
969
|
+
SELECT 1 FROM checked g
|
|
970
|
+
WHERE g.generation = ${committed.generation} AND g.created = ${committed.created}
|
|
971
|
+
AND g.event_sequence = ${committed.head}
|
|
972
|
+
AND g.cold_ref IS NULL
|
|
973
|
+
AND NOT EXISTS (SELECT 1 FROM actor_receipts WHERE ${actorRow}
|
|
974
|
+
AND command_id = ${request.commandId})
|
|
975
|
+
AND (${request.external !== true} OR
|
|
976
|
+
(g.now + ${identityOffset} >= ${issuedAt} AND g.now + ${identityOffset} < ${expiresAt}))
|
|
977
|
+
) THEN 1 ELSE 0 END AS accepted,
|
|
978
|
+
(SELECT set_config('durable.admitted_at_ms', now::text, true) FROM checked) AS now`;
|
|
979
|
+
const flight = [
|
|
980
|
+
begin,
|
|
981
|
+
Effect.asVoid(sql `SELECT ${timeouts}`),
|
|
982
|
+
Effect.map(guard, (rows) => {
|
|
983
|
+
startedAtMs = Number(rows[0].now);
|
|
984
|
+
if (request.external === true)
|
|
985
|
+
batch[0].admitted = true;
|
|
986
|
+
}).pipe(Effect.tapError((error) => Effect.sync(() => {
|
|
987
|
+
missed = Schema.is(Schema.Struct({ code: Schema.Literal("22012") }))(error.reason.cause);
|
|
988
|
+
}))),
|
|
989
|
+
...plan.writes,
|
|
990
|
+
Effect.map(control("COMMIT"), (result) => {
|
|
991
|
+
tag = result.command;
|
|
992
|
+
open = false;
|
|
993
|
+
}),
|
|
994
|
+
Effect.map(session().query(COMMIT_VERSION, [], true), (result) => {
|
|
995
|
+
const ended = result.rows[0];
|
|
996
|
+
version = ended.version;
|
|
997
|
+
endedAtMs = Number(ended.now);
|
|
998
|
+
}),
|
|
999
|
+
];
|
|
1000
|
+
return sendPipelined(flight).pipe(Effect.withSpan(SpanNames.commit), Effect.as(true), Effect.catch((error) => {
|
|
1001
|
+
const duplicate = Schema.is(Schema.Struct({
|
|
1002
|
+
code: Schema.Literal("23505"),
|
|
1003
|
+
constraint: Schema.Literal("actor_receipts_pkey"),
|
|
1004
|
+
}))(error.reason.cause);
|
|
1005
|
+
return (missed || duplicate) && tag === "ROLLBACK"
|
|
1006
|
+
? Effect.succeed(false)
|
|
1007
|
+
: Effect.fail(error);
|
|
1008
|
+
}), Effect.flatMap((accepted) => {
|
|
1009
|
+
if (!accepted) {
|
|
1010
|
+
forget(cache);
|
|
1011
|
+
return ordinary();
|
|
1012
|
+
}
|
|
1013
|
+
if (tag !== "COMMIT")
|
|
1014
|
+
return Effect.die(RetryTurn.make({ message: "Warm turn commit rolled back" }));
|
|
1015
|
+
return Effect.as(release, {
|
|
1016
|
+
plan: { ...plan, startedAtMs },
|
|
1017
|
+
version,
|
|
1018
|
+
endedAtMs,
|
|
1019
|
+
});
|
|
1020
|
+
}));
|
|
1021
|
+
}), inTurn, bounded);
|
|
1022
|
+
};
|
|
815
1023
|
const onMember = (member) => (effect) => Effect.suspend(() => Effect.provideService(effect, sql.transactionService, [
|
|
816
1024
|
asSqlConnection({ connection: member.connection(), send: member.send }),
|
|
817
1025
|
0,
|
|
@@ -906,17 +1114,60 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
906
1114
|
};
|
|
907
1115
|
}).pipe(Effect.catchDefect((defect) => defect instanceof Unseated ? Effect.undefined : Effect.die(defect)), Effect.onExit(() => member.leave(false)), bounded, Effect.map((ended) => ({ ended, following })));
|
|
908
1116
|
};
|
|
909
|
-
const transact = (batch, admitting) => admitting.
|
|
910
|
-
?
|
|
911
|
-
:
|
|
912
|
-
?
|
|
913
|
-
: Effect.flatMap(
|
|
1117
|
+
const transact = (batch, admitting) => admitting.optimistic !== undefined
|
|
1118
|
+
? optimistic(batch, admitting, admitting.optimistic)
|
|
1119
|
+
: admitting.member === undefined
|
|
1120
|
+
? alone(batch, admitting)
|
|
1121
|
+
: Effect.flatMap(grouped(batch, admitting.admission, admitting.member), (outcome) => outcome.ended !== undefined
|
|
1122
|
+
? Effect.succeed(outcome.ended)
|
|
1123
|
+
: Effect.flatMap(openAlone(batch), (opened) => alone(batch, opened, outcome.following)));
|
|
914
1124
|
let first = true;
|
|
915
1125
|
return yield* drive((batch) => {
|
|
916
|
-
const
|
|
1126
|
+
const snapshot = cache.committed;
|
|
1127
|
+
const { request, command } = batch[0];
|
|
1128
|
+
const fast = warmTurns &&
|
|
1129
|
+
batch.length === 1 &&
|
|
1130
|
+
cache.cold === undefined &&
|
|
1131
|
+
snapshot !== undefined &&
|
|
1132
|
+
snapshot.generation === cache.generation &&
|
|
1133
|
+
snapshot.state === cache.state &&
|
|
1134
|
+
!statements &&
|
|
1135
|
+
waited.size === 0 &&
|
|
1136
|
+
connections === undefined &&
|
|
1137
|
+
!run.publishesUnderLock(batch) &&
|
|
1138
|
+
!command.handler &&
|
|
1139
|
+
(!command.internal || isSystem(request.caller)) &&
|
|
1140
|
+
request.delivery === undefined &&
|
|
1141
|
+
request.redelivered !== true &&
|
|
1142
|
+
commandTimes(request.commandId).issuedAt > snapshot.replayBefore &&
|
|
1143
|
+
!snapshot.receipts.has(request.commandId) &&
|
|
1144
|
+
(policy.createdBy === undefined || snapshot.created);
|
|
1145
|
+
const joining = first &&
|
|
1146
|
+
cache.cold === undefined &&
|
|
1147
|
+
view().generation !== undefined &&
|
|
1148
|
+
view().state !== undefined
|
|
917
1149
|
? groups
|
|
918
1150
|
: undefined;
|
|
919
1151
|
first = false;
|
|
1152
|
+
if (fast)
|
|
1153
|
+
return leased.pipe(Effect.map(() => ({
|
|
1154
|
+
admission: admit(batch, [], view(), pipeline, [], false, {
|
|
1155
|
+
now: String(snapshot.now),
|
|
1156
|
+
generation: snapshot.generation,
|
|
1157
|
+
created: snapshot.created,
|
|
1158
|
+
cold_ref: null,
|
|
1159
|
+
cold_digest: null,
|
|
1160
|
+
cold_state_version: null,
|
|
1161
|
+
canonical: request.payload,
|
|
1162
|
+
caller_key: null,
|
|
1163
|
+
command: null,
|
|
1164
|
+
payload_hash: null,
|
|
1165
|
+
outcome: null,
|
|
1166
|
+
head: snapshot.head,
|
|
1167
|
+
}),
|
|
1168
|
+
admitted: [],
|
|
1169
|
+
optimistic: snapshot,
|
|
1170
|
+
})));
|
|
920
1171
|
return joining === undefined ? openAlone(batch) : openGrouped(batch, joining);
|
|
921
1172
|
}, transact).pipe(Effect.onExit((exit) => {
|
|
922
1173
|
if (Exit.isSuccess(exit) || !open)
|
|
@@ -960,6 +1211,7 @@ export const executeBatches = Effect.fnUntraced(function* (run, cache, routingKe
|
|
|
960
1211
|
: Option.getOrUndefined(yield* Effect.serviceOption(TurnGroups));
|
|
961
1212
|
const exit = yield* (Option.isSome(turns) ? pipelined(turns.value) : sequential).pipe(Effect.onError(() => Effect.sync(() => {
|
|
962
1213
|
cache.state = undefined;
|
|
1214
|
+
cache.committed = undefined;
|
|
963
1215
|
})), Effect.exit);
|
|
964
1216
|
if (Exit.isSuccess(exit))
|
|
965
1217
|
return undefined;
|
|
@@ -4,7 +4,7 @@ import type { Request } from "../request.ts";
|
|
|
4
4
|
* A named moment in command delivery, turn execution, and the outbox and
|
|
5
5
|
* subscription relays where a test can pause, fail, or crash the runtime.
|
|
6
6
|
*/
|
|
7
|
-
export type TurnPoint = "beforeDelivery" | "queued" | "beforeHandler" | "beforeCommit" | "afterCommit" | "beforeFlush" | "afterClaim" | "beforeOutboxDelete" | "beforeExecute" | "afterExecute" | "beforeRenew" | "beforeDeadLetterCommit" | "beforeSettle" | "afterSettleSnapshot" | "afterExpand" | "beforeWorkflowSuspend";
|
|
7
|
+
export type TurnPoint = "beforeDelivery" | "queued" | "beforeHandler" | "afterColdRollback" | "afterColdFetch" | "afterColdWriteBack" | "beforeCommit" | "afterCommit" | "beforeFlush" | "afterClaim" | "beforeOutboxDelete" | "beforeExecute" | "afterExecute" | "beforeRenew" | "beforeDeadLetterCommit" | "beforeSettle" | "afterSettleSnapshot" | "afterExpand" | "beforeWorkflowSuspend";
|
|
8
8
|
declare const RetryTurn_base: Schema.Class<RetryTurn, Schema.TaggedStruct<"RetryTurn", {
|
|
9
9
|
readonly message: Schema.String;
|
|
10
10
|
}>, import("effect/Cause").YieldableError>;
|
|
@@ -53,7 +53,7 @@ export declare const ControlPayload: Schema.fromJsonString<Schema.Struct<{
|
|
|
53
53
|
export declare const CallerJson: Schema.fromJsonString<Schema.toTaggedUnion<"_tag", readonly [Schema.TaggedStruct<"User", {
|
|
54
54
|
readonly subject: Schema.NonEmptyString;
|
|
55
55
|
}>, Schema.TaggedStruct<"Anonymous", {}>, Schema.TaggedStruct<"System", {
|
|
56
|
-
readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription"]>;
|
|
56
|
+
readonly source: Schema.Literals<readonly ["process", "actor", "timer", "cron", "workflow", "job", "subscription", "cold"]>;
|
|
57
57
|
readonly ref: Schema.optional<Schema.Struct<{
|
|
58
58
|
readonly tenant: Schema.NonEmptyString;
|
|
59
59
|
readonly actor: Schema.NonEmptyString;
|
|
@@ -28,7 +28,7 @@ export declare const checkReceipt: (request: {
|
|
|
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;
|
|
@@ -44,7 +44,7 @@ interface LocalExecutor {
|
|
|
44
44
|
* subscription work in `work`.
|
|
45
45
|
*/
|
|
46
46
|
interface ClaimedRow extends ClaimedJob {
|
|
47
|
-
readonly kind: "intent" | "job" | "skipped-intent" | "skipped-job" | "work";
|
|
47
|
+
readonly kind: "intent" | "job" | "cold" | "skipped-intent" | "skipped-job" | "skipped-cold" | "work";
|
|
48
48
|
readonly target_type: string;
|
|
49
49
|
readonly target_id: string;
|
|
50
50
|
readonly timer_key: string | null;
|
|
@@ -62,7 +62,7 @@ interface ClaimedRow extends ClaimedJob {
|
|
|
62
62
|
*/
|
|
63
63
|
export declare const candidates: ({ sql, kind, now, limit, only, range, }: {
|
|
64
64
|
readonly sql: SqlClient.SqlClient;
|
|
65
|
-
readonly kind: "intent" | "job" | "feed" | "control";
|
|
65
|
+
readonly kind: "intent" | "job" | "feed" | "control" | "cold";
|
|
66
66
|
readonly now: Statement.Fragment;
|
|
67
67
|
readonly limit: number;
|
|
68
68
|
readonly only?: Statement.Fragment;
|