@effect-app/infra 4.0.0-beta.263 → 4.0.0-beta.265
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 +32 -0
- package/dist/ClusterCosmos.d.ts +1 -1
- package/dist/ClusterCosmos.d.ts.map +1 -1
- package/dist/ClusterCosmos.js +158 -126
- package/dist/WorkflowEngineCosmos.d.ts.map +1 -1
- package/dist/WorkflowEngineCosmos.js +58 -37
- package/dist/WorkflowEngineSqlite.js +11 -11
- package/package.json +5 -5
- package/run.sh +7 -1
- package/src/ClusterCosmos.ts +300 -196
- package/src/WorkflowEngineCosmos.ts +70 -40
- package/src/WorkflowEngineSqlite.ts +10 -10
- package/test/cluster-cosmos.test.ts +25 -3
- package/test/cluster-sqlite.test.ts +2 -4
- package/test/dist/cluster-azure-sql.test.d.ts.map +1 -0
- package/test/dist/cluster-storage-parity-sqlite.test.d.ts.map +1 -0
- package/test/workflow-engine-cosmos.test.ts +13 -22
- package/test/workflow-engine-sqlite.test.ts +2 -4
|
@@ -34,6 +34,7 @@ import * as Effect from "effect-app/Effect"
|
|
|
34
34
|
import * as Layer from "effect-app/Layer"
|
|
35
35
|
import * as Option from "effect-app/Option"
|
|
36
36
|
import * as S from "effect-app/Schema"
|
|
37
|
+
import * as Cause from "effect/Cause"
|
|
37
38
|
import * as Duration from "effect/Duration"
|
|
38
39
|
import * as Exit from "effect/Exit"
|
|
39
40
|
import * as Fiber from "effect/Fiber"
|
|
@@ -109,12 +110,24 @@ interface ClockDoc {
|
|
|
109
110
|
readonly fireAt: string
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// Cosmos forbids '/', '\', '#', '?' in a resource id; workflow/deferred/clock
|
|
114
|
+
// names routinely contain '/', so every doc id is URI-encoded (mirrors
|
|
115
|
+
// `cosmosId` in ClusterCosmos). Partition keys (executionId) are exempt.
|
|
116
|
+
const cosmosId = (id: string) => encodeURIComponent(id)
|
|
112
117
|
const execId = "exec" as const
|
|
113
|
-
const activityKey = (name: string, attempt: number) => `activity::${name}::${attempt}`
|
|
114
|
-
const deferredKey = (name: string) => `deferred::${name}`
|
|
115
|
-
const clockKey = (name: string) => `clock::${name}`
|
|
118
|
+
const activityKey = (name: string, attempt: number) => cosmosId(`activity::${name}::${attempt}`)
|
|
119
|
+
const deferredKey = (name: string) => cosmosId(`deferred::${name}`)
|
|
120
|
+
const clockKey = (name: string) => cosmosId(`clock::${name}`)
|
|
116
121
|
|
|
117
|
-
|
|
122
|
+
// Single-item Cosmos writes (`replace`) *throw* on 409/412/404 rather than
|
|
123
|
+
// returning the status code (only `read` and batch ops surface codes), so OCC
|
|
124
|
+
// conflicts must be matched on the thrown error — mirrors ClusterCosmos.
|
|
125
|
+
const isCosmosStatus = (u: unknown, code: number): boolean =>
|
|
126
|
+
Cause.isUnknownError(u)
|
|
127
|
+
? isCosmosStatus(u.cause, code)
|
|
128
|
+
: typeof u === "object" && u !== null && "code" in u && u.code === code
|
|
129
|
+
|
|
130
|
+
const isOptimisticError = (u: unknown) => isCosmosStatus(u, 409) || isCosmosStatus(u, 412) || isCosmosStatus(u, 404)
|
|
118
131
|
|
|
119
132
|
// --- Storage codecs ----------------------------------------------------------
|
|
120
133
|
// Values flowing through the engine's activity / deferred boundary are already
|
|
@@ -122,7 +135,7 @@ const isOptimisticStatus = (code: number) => code === 409 || code === 412 || cod
|
|
|
122
135
|
// opaque (mirrors the cluster engine's `AnyOrVoid` usage).
|
|
123
136
|
const AnyOrVoid = S.Union([S.Any, S.Void])
|
|
124
137
|
const ActivityResultCodec = S.fromJsonString(S.toCodecJson(Workflow.Result({ success: AnyOrVoid, error: AnyOrVoid })))
|
|
125
|
-
const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect)))
|
|
138
|
+
const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect())))
|
|
126
139
|
|
|
127
140
|
const encodeActivityResult = (r: Workflow.Result<unknown, unknown>) =>
|
|
128
141
|
Effect.orDie(S.encodeEffect(ActivityResultCodec)(r))
|
|
@@ -183,10 +196,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
183
196
|
const makePayloadCodec = (workflow: Workflow.Any) => S.fromJsonString(S.toCodecJson(workflow.payloadSchema))
|
|
184
197
|
const payloadCodecCache = new Map<string, ReturnType<typeof makePayloadCodec>>()
|
|
185
198
|
const payloadCodecFor = (workflow: Workflow.Any) => {
|
|
186
|
-
let c = payloadCodecCache.get(workflow.
|
|
199
|
+
let c = payloadCodecCache.get(workflow._tag)
|
|
187
200
|
if (!c) {
|
|
188
201
|
c = makePayloadCodec(workflow)
|
|
189
|
-
payloadCodecCache.set(workflow.
|
|
202
|
+
payloadCodecCache.set(workflow._tag, c)
|
|
190
203
|
}
|
|
191
204
|
return c
|
|
192
205
|
}
|
|
@@ -195,10 +208,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
195
208
|
S.fromJsonString(S.toCodecJson(Workflow.Result({ success: workflow.successSchema, error: workflow.errorSchema })))
|
|
196
209
|
const resultCodecCache = new Map<string, ReturnType<typeof makeResultCodec>>()
|
|
197
210
|
const resultCodecFor = (workflow: Workflow.Any) => {
|
|
198
|
-
let c = resultCodecCache.get(workflow.
|
|
211
|
+
let c = resultCodecCache.get(workflow._tag)
|
|
199
212
|
if (!c) {
|
|
200
213
|
c = makeResultCodec(workflow)
|
|
201
|
-
resultCodecCache.set(workflow.
|
|
214
|
+
resultCodecCache.set(workflow._tag, c)
|
|
202
215
|
}
|
|
203
216
|
return c
|
|
204
217
|
}
|
|
@@ -228,22 +241,24 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
228
241
|
const replaceExec = (doc: ExecDoc) =>
|
|
229
242
|
Effect
|
|
230
243
|
.gen(function*() {
|
|
231
|
-
const resp = yield* Effect.
|
|
244
|
+
const resp = yield* Effect.tryPromise(() =>
|
|
232
245
|
container.item(execId, doc._partitionKey).replace<ExecDoc>(doc, {
|
|
233
246
|
accessCondition: { type: "IfMatch", condition: doc._etag ?? "" }
|
|
234
247
|
})
|
|
235
248
|
)
|
|
236
249
|
yield* annotateCosmosResponse({ requestCharge: resp.requestCharge, statusCode: resp.statusCode })
|
|
237
|
-
if (isOptimisticStatus(resp.statusCode)) {
|
|
238
|
-
return yield* new OptimisticConcurrencyException({
|
|
239
|
-
type: "workflow.exec",
|
|
240
|
-
id: doc._partitionKey,
|
|
241
|
-
code: resp.statusCode
|
|
242
|
-
})
|
|
243
|
-
}
|
|
244
250
|
return { ...doc, _etag: resp.etag }
|
|
245
251
|
})
|
|
246
|
-
.pipe(
|
|
252
|
+
.pipe(
|
|
253
|
+
Effect.catch((u) =>
|
|
254
|
+
isOptimisticError(u)
|
|
255
|
+
? Effect.fail(
|
|
256
|
+
new OptimisticConcurrencyException({ type: "workflow.exec", id: doc._partitionKey, code: 412 })
|
|
257
|
+
)
|
|
258
|
+
: Effect.die(u)
|
|
259
|
+
),
|
|
260
|
+
annotate("replaceExec", doc._partitionKey)
|
|
261
|
+
)
|
|
247
262
|
|
|
248
263
|
// Atomic create-or-noop using a single-op batch — returns true if created.
|
|
249
264
|
const createIfMissing = <T extends { readonly id: string; readonly _partitionKey: string }>(
|
|
@@ -390,7 +405,10 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
390
405
|
...current.value,
|
|
391
406
|
status: isComplete ? "complete" : current.value.status,
|
|
392
407
|
suspended: result._tag === "Suspended",
|
|
393
|
-
|
|
408
|
+
// Never downgrade a persisted interrupt: a concurrent `interrupt` may
|
|
409
|
+
// have set the flag while this driver was suspending. Losing it would
|
|
410
|
+
// leave a re-drive unable to collapse the suspension.
|
|
411
|
+
interrupted: instance.interrupted || current.value.interrupted,
|
|
394
412
|
completedResult,
|
|
395
413
|
// Release lease on completion so the doc isn't seen as orphaned.
|
|
396
414
|
worker: isComplete ? undefined : current.value.worker,
|
|
@@ -449,38 +467,59 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
449
467
|
)
|
|
450
468
|
})
|
|
451
469
|
|
|
470
|
+
// Persist `interrupted: true`, retrying on OCC. A suspending driver's
|
|
471
|
+
// onComplete (or a heartbeat renewal) can win the etag race; silently
|
|
472
|
+
// dropping the flag would leave the subsequent re-drive reading
|
|
473
|
+
// `interrupted: false` and re-suspending forever. Retry until the flag is
|
|
474
|
+
// durably set (or the exec is already complete/gone). Unlike SQLite — whose
|
|
475
|
+
// synchronous writes never interleave here — Cosmos round-trips open a real
|
|
476
|
+
// race window, so the write must converge rather than swallow the conflict.
|
|
477
|
+
const markInterrupted = (executionId: string): Effect.Effect<void> =>
|
|
478
|
+
Effect.gen(function*() {
|
|
479
|
+
while (true) {
|
|
480
|
+
const current = yield* readExec(executionId)
|
|
481
|
+
if (Option.isNone(current) || current.value.status === "complete" || current.value.interrupted) {
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
const persisted = yield* replaceExec({ ...current.value, interrupted: true }).pipe(
|
|
485
|
+
Effect.as(true),
|
|
486
|
+
Effect.catchTag("OptimisticConcurrencyException", () => Effect.succeed(false))
|
|
487
|
+
)
|
|
488
|
+
if (persisted) return
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
452
492
|
// --- Encoded engine ----------------------------------------------------
|
|
453
493
|
|
|
454
494
|
const encoded: Encoded = {
|
|
455
495
|
register: Effect.fnUntraced(function*(workflow, execute) {
|
|
456
|
-
workflows.set(workflow.
|
|
496
|
+
workflows.set(workflow._tag, {
|
|
457
497
|
workflow,
|
|
458
498
|
execute,
|
|
459
499
|
scope: yield* Effect.scope
|
|
460
500
|
})
|
|
461
501
|
}),
|
|
462
502
|
execute: Effect.fnUntraced(function*(workflow, options) {
|
|
463
|
-
const entry = workflows.get(workflow.
|
|
503
|
+
const entry = workflows.get(workflow._tag)
|
|
464
504
|
if (!entry) {
|
|
465
|
-
return yield* Effect.orDie(Effect.fail(`Workflow ${workflow.
|
|
505
|
+
return yield* Effect.orDie(Effect.fail(`Workflow ${workflow._tag} is not registered`))
|
|
466
506
|
}
|
|
467
507
|
|
|
468
508
|
const initial: ExecDoc = {
|
|
469
509
|
id: execId,
|
|
470
510
|
_partitionKey: options.executionId,
|
|
471
511
|
type: "exec",
|
|
472
|
-
workflowName: workflow.
|
|
512
|
+
workflowName: workflow._tag,
|
|
473
513
|
payload: yield* encodePayload(workflow, options.payload),
|
|
474
514
|
parent: options.parent?.executionId,
|
|
475
515
|
status: "running",
|
|
476
516
|
suspended: false,
|
|
477
517
|
interrupted: false
|
|
478
518
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
}
|
|
519
|
+
yield* createIfMissing(initial).pipe(annotate("execute.claim", options.executionId))
|
|
520
|
+
// Drive unconditionally; `drive`'s own guard short-circuits a still-running
|
|
521
|
+
// or completed fiber and re-drives a suspended one (matches Sqlite).
|
|
522
|
+
yield* drive(options.executionId, options.payload, options.parent?.executionId, entry)
|
|
484
523
|
|
|
485
524
|
if (options.discard) return undefined as any
|
|
486
525
|
|
|
@@ -515,22 +554,13 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
515
554
|
interrupt: Effect.fnUntraced(function*(_workflow, executionId) {
|
|
516
555
|
const local = locals.get(executionId)
|
|
517
556
|
if (local) local.instance.interrupted = true
|
|
518
|
-
|
|
519
|
-
if (Option.isNone(current) || current.value.status === "complete") return
|
|
520
|
-
yield* replaceExec({ ...current.value, interrupted: true }).pipe(
|
|
521
|
-
Effect.catchTag("OptimisticConcurrencyException", () => Effect.void)
|
|
522
|
-
)
|
|
557
|
+
yield* markInterrupted(executionId)
|
|
523
558
|
yield* driveById(executionId)
|
|
524
559
|
}),
|
|
525
560
|
interruptUnsafe: Effect.fnUntraced(function*(_workflow, executionId) {
|
|
526
561
|
const local = locals.get(executionId)
|
|
527
562
|
if (local) local.instance.interrupted = true
|
|
528
|
-
|
|
529
|
-
if (Option.isSome(current) && current.value.status !== "complete") {
|
|
530
|
-
yield* replaceExec({ ...current.value, interrupted: true }).pipe(
|
|
531
|
-
Effect.catchTag("OptimisticConcurrencyException", () => Effect.void)
|
|
532
|
-
)
|
|
533
|
-
}
|
|
563
|
+
yield* markInterrupted(executionId)
|
|
534
564
|
if (local?.fiber) yield* Fiber.interrupt(local.fiber)
|
|
535
565
|
}),
|
|
536
566
|
resume: (_workflow, executionId) => driveById(executionId),
|
|
@@ -602,7 +632,7 @@ const makeCosmosWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
602
632
|
id: clockKey(options.clock.name),
|
|
603
633
|
_partitionKey: options.executionId,
|
|
604
634
|
type: "clock",
|
|
605
|
-
workflowName: workflow.
|
|
635
|
+
workflowName: workflow._tag,
|
|
606
636
|
deferredName: options.clock.deferred.name,
|
|
607
637
|
fireAt
|
|
608
638
|
}
|
|
@@ -117,7 +117,7 @@ const parseExec = (row: ExecRow): ExecState => ({
|
|
|
117
117
|
// opaque (mirrors the cluster engine's `AnyOrVoid` usage).
|
|
118
118
|
const AnyOrVoid = S.Union([S.Any, S.Void])
|
|
119
119
|
const ActivityResultCodec = S.fromJsonString(S.toCodecJson(Workflow.Result({ success: AnyOrVoid, error: AnyOrVoid })))
|
|
120
|
-
const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect)))
|
|
120
|
+
const DeferredExitCodec = S.fromJsonString(S.toCodecJson(S.Exit(AnyOrVoid, AnyOrVoid, S.Defect())))
|
|
121
121
|
|
|
122
122
|
const encodeActivityResult = (r: Workflow.Result<unknown, unknown>) =>
|
|
123
123
|
Effect.orDie(S.encodeEffect(ActivityResultCodec)(r))
|
|
@@ -229,10 +229,10 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
229
229
|
const makePayloadCodec = (workflow: Workflow.Any) => S.fromJsonString(S.toCodecJson(workflow.payloadSchema))
|
|
230
230
|
const payloadCodecCache = new Map<string, ReturnType<typeof makePayloadCodec>>()
|
|
231
231
|
const payloadCodecFor = (workflow: Workflow.Any) => {
|
|
232
|
-
let c = payloadCodecCache.get(workflow.
|
|
232
|
+
let c = payloadCodecCache.get(workflow._tag)
|
|
233
233
|
if (!c) {
|
|
234
234
|
c = makePayloadCodec(workflow)
|
|
235
|
-
payloadCodecCache.set(workflow.
|
|
235
|
+
payloadCodecCache.set(workflow._tag, c)
|
|
236
236
|
}
|
|
237
237
|
return c
|
|
238
238
|
}
|
|
@@ -241,10 +241,10 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
241
241
|
S.fromJsonString(S.toCodecJson(Workflow.Result({ success: workflow.successSchema, error: workflow.errorSchema })))
|
|
242
242
|
const resultCodecCache = new Map<string, ReturnType<typeof makeResultCodec>>()
|
|
243
243
|
const resultCodecFor = (workflow: Workflow.Any) => {
|
|
244
|
-
let c = resultCodecCache.get(workflow.
|
|
244
|
+
let c = resultCodecCache.get(workflow._tag)
|
|
245
245
|
if (!c) {
|
|
246
246
|
c = makeResultCodec(workflow)
|
|
247
|
-
resultCodecCache.set(workflow.
|
|
247
|
+
resultCodecCache.set(workflow._tag, c)
|
|
248
248
|
}
|
|
249
249
|
return c
|
|
250
250
|
}
|
|
@@ -602,20 +602,20 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
602
602
|
|
|
603
603
|
const encoded: Encoded = {
|
|
604
604
|
register: Effect.fnUntraced(function*(workflow, execute) {
|
|
605
|
-
workflows.set(workflow.
|
|
605
|
+
workflows.set(workflow._tag, {
|
|
606
606
|
workflow,
|
|
607
607
|
execute,
|
|
608
608
|
scope: yield* Effect.scope
|
|
609
609
|
})
|
|
610
610
|
}),
|
|
611
611
|
execute: Effect.fnUntraced(function*(workflow, options) {
|
|
612
|
-
const entry = workflows.get(workflow.
|
|
612
|
+
const entry = workflows.get(workflow._tag)
|
|
613
613
|
if (!entry) {
|
|
614
|
-
return yield* Effect.orDie(Effect.fail(`Workflow ${workflow.
|
|
614
|
+
return yield* Effect.orDie(Effect.fail(`Workflow ${workflow._tag} is not registered`))
|
|
615
615
|
}
|
|
616
616
|
const initial: ExecState = {
|
|
617
617
|
executionId: options.executionId,
|
|
618
|
-
workflowName: workflow.
|
|
618
|
+
workflowName: workflow._tag,
|
|
619
619
|
payload: yield* encodePayload(workflow, options.payload),
|
|
620
620
|
parent: options.parent?.executionId,
|
|
621
621
|
status: "running",
|
|
@@ -730,7 +730,7 @@ const makeSqliteWorkflowEngine = Effect.fnUntraced(function*(cfg: WorkflowEngine
|
|
|
730
730
|
yield* insertClock(
|
|
731
731
|
options.executionId,
|
|
732
732
|
options.clock.name,
|
|
733
|
-
workflow.
|
|
733
|
+
workflow._tag,
|
|
734
734
|
options.clock.deferred.name,
|
|
735
735
|
fireAt
|
|
736
736
|
)
|
|
@@ -11,6 +11,7 @@ const cosmosUrl = process.env["COSMOS_TEST_URL"]
|
|
|
11
11
|
const cosmosDb = process.env["COSMOS_TEST_DB"] ?? "cluster-test"
|
|
12
12
|
const testRunId = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`
|
|
13
13
|
const runnerPortBase = 10000 + Date.now() % 40000
|
|
14
|
+
const liveSnowflake = Layer.effect(Snowflake.Generator, TestClock.withLive(Snowflake.makeGenerator))
|
|
14
15
|
|
|
15
16
|
const layerFor = () =>
|
|
16
17
|
layerCosmos({
|
|
@@ -19,7 +20,7 @@ const layerFor = () =>
|
|
|
19
20
|
prefix: "test-cluster-"
|
|
20
21
|
})
|
|
21
22
|
.pipe(
|
|
22
|
-
Layer.provideMerge(
|
|
23
|
+
Layer.provideMerge(liveSnowflake),
|
|
23
24
|
Layer.provide(ShardingConfig.layerDefaults)
|
|
24
25
|
)
|
|
25
26
|
|
|
@@ -182,6 +183,28 @@ describe.skipIf(!cosmosUrl)("ClusterCosmos RunnerStorage", () => {
|
|
|
182
183
|
})
|
|
183
184
|
.pipe(Effect.provide(layerFor())))
|
|
184
185
|
|
|
186
|
+
it.effect("allocates distinct machine ids for distinct runners", () =>
|
|
187
|
+
Effect
|
|
188
|
+
.gen(function*() {
|
|
189
|
+
const storage = yield* RunnerStorage.RunnerStorage
|
|
190
|
+
const address1 = testRunnerAddress(6)
|
|
191
|
+
const address2 = testRunnerAddress(7)
|
|
192
|
+
const runner1 = Runner.make({ address: address1, groups: ["default"], weight: 1 })
|
|
193
|
+
const runner2 = Runner.make({ address: address2, groups: ["default"], weight: 1 })
|
|
194
|
+
|
|
195
|
+
const id1 = yield* storage.register(runner1, true)
|
|
196
|
+
const id2 = yield* storage.register(runner2, true)
|
|
197
|
+
// Distinct runners must get distinct machine ids (would otherwise risk
|
|
198
|
+
// colliding Snowflake ids).
|
|
199
|
+
assert(id1 !== id2)
|
|
200
|
+
// Re-registration is stable per address.
|
|
201
|
+
assert.deepStrictEqual(yield* storage.register(runner1, true), id1)
|
|
202
|
+
|
|
203
|
+
yield* storage.unregister(address1)
|
|
204
|
+
yield* storage.unregister(address2)
|
|
205
|
+
})
|
|
206
|
+
.pipe(Effect.provide(layerFor())))
|
|
207
|
+
|
|
185
208
|
it.effect("preserves shard lock ownership when two runners acquire concurrently", () =>
|
|
186
209
|
Effect
|
|
187
210
|
.gen(function*() {
|
|
@@ -342,8 +365,7 @@ const CosmosRpcEntityLayer = CosmosRpcEntity.toLayer(
|
|
|
342
365
|
const CosmosDeferred = DurableDeferred.make("ClusterCosmos/Deferred", { success: Schema.String })
|
|
343
366
|
|
|
344
367
|
const CosmosDeferredWorkflow = Workflow
|
|
345
|
-
.make({
|
|
346
|
-
name: "ClusterCosmos/DeferredWorkflow",
|
|
368
|
+
.make("ClusterCosmos/DeferredWorkflow", {
|
|
347
369
|
payload: { id: Schema.String },
|
|
348
370
|
success: Schema.String,
|
|
349
371
|
idempotencyKey: ({ id }) => id
|
|
@@ -67,8 +67,7 @@ class StepLog extends Context.Service<StepLog, { readonly steps: Array<string> }
|
|
|
67
67
|
const SqliteDeferred = DurableDeferred.make("ClusterSqlite/Deferred", { success: Schema.String })
|
|
68
68
|
|
|
69
69
|
const SqliteDeferredWorkflow = Workflow
|
|
70
|
-
.make({
|
|
71
|
-
name: "ClusterSqlite/DeferredWorkflow",
|
|
70
|
+
.make("ClusterSqlite/DeferredWorkflow", {
|
|
72
71
|
payload: { id: Schema.String },
|
|
73
72
|
success: Schema.String,
|
|
74
73
|
idempotencyKey: ({ id }) => id
|
|
@@ -85,8 +84,7 @@ const SequentialStep2 = DurableDeferred.make("ClusterSqlite/SequentialStep2", {
|
|
|
85
84
|
const SequentialStep3 = DurableDeferred.make("ClusterSqlite/SequentialStep3", { success: Schema.String })
|
|
86
85
|
|
|
87
86
|
const SequentialDeferredWorkflow = Workflow
|
|
88
|
-
.make({
|
|
89
|
-
name: "ClusterSqlite/SequentialDeferredWorkflow",
|
|
87
|
+
.make("ClusterSqlite/SequentialDeferredWorkflow", {
|
|
90
88
|
payload: { id: Schema.String },
|
|
91
89
|
success: Schema.String,
|
|
92
90
|
idempotencyKey: ({ id }) => id
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cluster-azure-sql.test.d.ts","sourceRoot":"","sources":["../cluster-azure-sql.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cluster-storage-parity-sqlite.test.d.ts","sourceRoot":"","sources":["../cluster-storage-parity-sqlite.test.ts"],"names":[],"mappings":""}
|
|
@@ -29,8 +29,7 @@ class CounterRef extends Context.Service<CounterRef, { count: number }>()("Count
|
|
|
29
29
|
|
|
30
30
|
// --- Workflow definitions ----------------------------------------------
|
|
31
31
|
|
|
32
|
-
const IncrementWorkflow = Workflow.make({
|
|
33
|
-
name: "WorkflowEngineCosmos/IncrementWorkflow",
|
|
32
|
+
const IncrementWorkflow = Workflow.make("WorkflowEngineCosmos/IncrementWorkflow", {
|
|
34
33
|
payload: { value: Schema.Number },
|
|
35
34
|
success: Schema.Number,
|
|
36
35
|
idempotencyKey: ({ value }) => String(value)
|
|
@@ -40,8 +39,7 @@ const IncrementHandler = IncrementWorkflow.toLayer(({ value }) => Effect.succeed
|
|
|
40
39
|
|
|
41
40
|
// Counts activity body invocations across re-executes so the test can
|
|
42
41
|
// prove side-effects don't repeat when a persisted result is available.
|
|
43
|
-
const CounterWorkflow = Workflow.make({
|
|
44
|
-
name: "WorkflowEngineCosmos/CounterWorkflow",
|
|
42
|
+
const CounterWorkflow = Workflow.make("WorkflowEngineCosmos/CounterWorkflow", {
|
|
45
43
|
payload: { id: Schema.String },
|
|
46
44
|
success: Schema.Number,
|
|
47
45
|
idempotencyKey: ({ id }) => id
|
|
@@ -63,8 +61,7 @@ const CounterHandler = CounterWorkflow.toLayer(Effect.fn(function*() {
|
|
|
63
61
|
// with the resumed deferred value. Exercises Result/Exit round-trip.
|
|
64
62
|
const Trigger = DurableDeferred.make("WorkflowEngineCosmos/Trigger", { success: Schema.String })
|
|
65
63
|
|
|
66
|
-
const SuspendWorkflow = Workflow.make({
|
|
67
|
-
name: "WorkflowEngineCosmos/SuspendWorkflow",
|
|
64
|
+
const SuspendWorkflow = Workflow.make("WorkflowEngineCosmos/SuspendWorkflow", {
|
|
68
65
|
payload: { id: Schema.String },
|
|
69
66
|
success: Schema.String,
|
|
70
67
|
idempotencyKey: ({ id }) => id
|
|
@@ -81,8 +78,7 @@ const SuspendHandler = SuspendWorkflow.toLayer(Effect.fn(function*({ id }) {
|
|
|
81
78
|
}))
|
|
82
79
|
|
|
83
80
|
// Plain durable-deferred await — used to assert first-writer-wins on done().
|
|
84
|
-
const AwaitOnly = Workflow.make({
|
|
85
|
-
name: "WorkflowEngineCosmos/AwaitOnly",
|
|
81
|
+
const AwaitOnly = Workflow.make("WorkflowEngineCosmos/AwaitOnly", {
|
|
86
82
|
payload: { id: Schema.String },
|
|
87
83
|
success: Schema.String,
|
|
88
84
|
idempotencyKey: ({ id }) => id
|
|
@@ -201,16 +197,11 @@ const runSuite = (engineLayer: Layer.Layer<WorkflowEngine.WorkflowEngine>) => {
|
|
|
201
197
|
yield* Effect.sleep(Duration.millis(50))
|
|
202
198
|
yield* AwaitOnly.interrupt(executionId)
|
|
203
199
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
yield* Effect.sleep(Duration.millis(150))
|
|
210
|
-
const polled = yield* AwaitOnly.poll(executionId)
|
|
211
|
-
if (Option.isSome(polled)) {
|
|
212
|
-
assert.strictEqual(polled.value._tag, "Complete")
|
|
213
|
-
}
|
|
200
|
+
// Interrupt collapses the suspended execution into a completion. A
|
|
201
|
+
// durable engine re-drives across several storage round-trips, so poll
|
|
202
|
+
// until it reports Complete rather than asserting a fixed delay.
|
|
203
|
+
const done = yield* waitForComplete(AwaitOnly, executionId)
|
|
204
|
+
assert(done !== undefined && done._tag === "Complete")
|
|
214
205
|
})
|
|
215
206
|
.pipe(Effect.provide(TestLayer)))
|
|
216
207
|
}
|
|
@@ -278,7 +269,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
|
|
|
278
269
|
id: "exec",
|
|
279
270
|
_partitionKey: "recover-1",
|
|
280
271
|
type: "exec",
|
|
281
|
-
workflowName: IncrementWorkflow.
|
|
272
|
+
workflowName: IncrementWorkflow._tag,
|
|
282
273
|
payload: JSON.stringify({ value: 99 }),
|
|
283
274
|
status: "running",
|
|
284
275
|
suspended: false,
|
|
@@ -318,7 +309,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
|
|
|
318
309
|
id: "exec",
|
|
319
310
|
_partitionKey: "exec-clock",
|
|
320
311
|
type: "exec",
|
|
321
|
-
workflowName: AwaitOnly.
|
|
312
|
+
workflowName: AwaitOnly._tag,
|
|
322
313
|
payload: JSON.stringify({ id: "wake" }),
|
|
323
314
|
status: "running",
|
|
324
315
|
suspended: false,
|
|
@@ -331,7 +322,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
|
|
|
331
322
|
id: "clock::wake",
|
|
332
323
|
_partitionKey: "exec-clock",
|
|
333
324
|
type: "clock",
|
|
334
|
-
workflowName: AwaitOnly.
|
|
325
|
+
workflowName: AwaitOnly._tag,
|
|
335
326
|
deferredName: Trigger.name,
|
|
336
327
|
fireAt: new Date(Date.now() - 60_000).toISOString()
|
|
337
328
|
})
|
|
@@ -342,7 +333,7 @@ describe.skipIf(!cosmosUrl)("WorkflowEngine (Cosmos) — adapter internals", ()
|
|
|
342
333
|
// The clock fire is a deferred-complete; assert the deferred row
|
|
343
334
|
// now exists for this execution.
|
|
344
335
|
const deferred = yield* Effect.promise(() =>
|
|
345
|
-
container.item(`deferred::${Trigger.name}
|
|
336
|
+
container.item(encodeURIComponent(`deferred::${Trigger.name}`), "exec-clock").read<{ exit: string }>()
|
|
346
337
|
)
|
|
347
338
|
assert(deferred.resource !== undefined)
|
|
348
339
|
// And the clock doc has been deleted.
|
|
@@ -21,8 +21,7 @@ class CounterRef extends Context.Service<CounterRef, { count: number }>()("Count
|
|
|
21
21
|
|
|
22
22
|
// --- Workflow definitions --------------------------------------------
|
|
23
23
|
|
|
24
|
-
const Increment = Workflow.make({
|
|
25
|
-
name: "Sqlite/Increment",
|
|
24
|
+
const Increment = Workflow.make("Sqlite/Increment", {
|
|
26
25
|
payload: { value: Schema.Number },
|
|
27
26
|
success: Schema.Number,
|
|
28
27
|
idempotencyKey: ({ value }) => `inc-${value}`
|
|
@@ -45,8 +44,7 @@ const TickHandler = Increment.toLayer(({ value }) => Effect.succeed(value + 1))
|
|
|
45
44
|
|
|
46
45
|
const EmailReceived = DurableDeferred.make("EmailReceived", { success: Schema.String })
|
|
47
46
|
|
|
48
|
-
const AwaitEmail = Workflow.make({
|
|
49
|
-
name: "Sqlite/AwaitEmail",
|
|
47
|
+
const AwaitEmail = Workflow.make("Sqlite/AwaitEmail", {
|
|
50
48
|
payload: { id: Schema.String },
|
|
51
49
|
success: Schema.String,
|
|
52
50
|
idempotencyKey: ({ id }) => `email-${id}`
|