@smthrs/engine-store 1.0.0-rc.6 → 1.0.0-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +13 -11
  2. package/dist/cjs/ArtifactSync.d.ts.map +1 -1
  3. package/dist/cjs/ArtifactSync.js +5 -0
  4. package/dist/cjs/ArtifactSync.js.map +2 -2
  5. package/dist/cjs/DisasterRecovery.d.ts +9 -26
  6. package/dist/cjs/DisasterRecovery.d.ts.map +1 -1
  7. package/dist/cjs/DisasterRecovery.js +27 -18
  8. package/dist/cjs/DisasterRecovery.js.map +2 -2
  9. package/dist/cjs/StepBoundary.d.ts +8 -6
  10. package/dist/cjs/StepBoundary.d.ts.map +1 -1
  11. package/dist/cjs/StepBoundary.js +10 -7
  12. package/dist/cjs/StepBoundary.js.map +2 -2
  13. package/dist/cjs/index.d.ts +12 -0
  14. package/dist/cjs/index.d.ts.map +1 -1
  15. package/dist/cjs/index.js.map +2 -2
  16. package/dist/cjs/internal/ActionPersistence.d.ts.map +1 -1
  17. package/dist/cjs/internal/ActionPersistence.js +27 -2
  18. package/dist/cjs/internal/ActionPersistence.js.map +3 -3
  19. package/dist/esm/ArtifactSync.d.ts.map +1 -1
  20. package/dist/esm/ArtifactSync.js +6 -1
  21. package/dist/esm/ArtifactSync.js.map +1 -1
  22. package/dist/esm/DisasterRecovery.d.ts +9 -26
  23. package/dist/esm/DisasterRecovery.d.ts.map +1 -1
  24. package/dist/esm/DisasterRecovery.js +46 -27
  25. package/dist/esm/DisasterRecovery.js.map +1 -1
  26. package/dist/esm/StepBoundary.d.ts +8 -6
  27. package/dist/esm/StepBoundary.d.ts.map +1 -1
  28. package/dist/esm/StepBoundary.js +17 -8
  29. package/dist/esm/StepBoundary.js.map +1 -1
  30. package/dist/esm/index.d.ts +12 -0
  31. package/dist/esm/index.d.ts.map +1 -1
  32. package/dist/esm/index.js +12 -0
  33. package/dist/esm/index.js.map +1 -1
  34. package/dist/esm/internal/ActionPersistence.d.ts.map +1 -1
  35. package/dist/esm/internal/ActionPersistence.js +59 -3
  36. package/dist/esm/internal/ActionPersistence.js.map +1 -1
  37. package/docs/README.md +3 -1
  38. package/docs/api.md +23 -17
  39. package/docs/concepts/attempts-and-replay.md +15 -2
  40. package/docs/guides/back-up-and-restore.md +9 -3
  41. package/docs/guides/reclaim-runs-from-a-dead-host.md +16 -0
  42. package/docs/installation.md +5 -1
  43. package/docs/quickstart.md +9 -1
  44. package/docs/troubleshooting.md +3 -0
  45. package/package.json +18 -18
  46. package/src/ArtifactSync.ts +7 -0
  47. package/src/DisasterRecovery.ts +90 -58
  48. package/src/StepBoundary.ts +29 -14
  49. package/src/index.ts +14 -0
  50. package/src/internal/ActionPersistence.ts +64 -3
@@ -1229,7 +1229,21 @@ export const make = (deps: Dependencies) => {
1229
1229
  // permits and interleave the very span the permit serializes. The step
1230
1230
  // key, not the narrowed cache address, is what serializes here, because
1231
1231
  // the attempt rows two scopes of one key share are what they race over.
1232
- return yield* admission.withPermit(`${deps.runId}|${stepKeyDigest}`)(
1232
+ //
1233
+ // A cache-eligible dispatch also holds the permit for its cache address,
1234
+ // taken second so every dispatch acquires the two in one order (issue
1235
+ // #3898). Runs share that address, so a second run asking for a result
1236
+ // this process is already computing waits for it and is answered by its
1237
+ // cache row instead of running the body beside it. That includes a run
1238
+ // the stale-running sweep reclaimed from a dead owner, whose in-flight
1239
+ // attempt would otherwise re-execute next to the run that superseded
1240
+ // it. Other processes are not excluded: two processes may still execute
1241
+ // one key at once, and the second recording resolves as before.
1242
+ const exclusive = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
1243
+ admission.withPermit(`run:${deps.runId}|${stepKeyDigest}`)(
1244
+ cacheDeclaration._tag === "Eligible" ? admission.withPermit(`cache:${keyDigest}`)(effect) : effect
1245
+ )
1246
+ return yield* exclusive(
1233
1247
  Effect.gen(function*() {
1234
1248
  // Lifecycle announcements take a per-attempt producer identity
1235
1249
  // (issue #91): adoption — or a replay after a crash in the
@@ -1476,6 +1490,26 @@ export const make = (deps: Dependencies) => {
1476
1490
  // A run's admitted attempt is replay authority. Shared cache rows
1477
1491
  // are replaceable and cannot overwrite its result or failure.
1478
1492
  const existing = yield* attempts.get(attemptId)
1493
+ /**
1494
+ * AN ORPHANED ATTEMPT ASKS THE CACHE FIRST (issue #3898). A `running`
1495
+ * row read under the permit is crash evidence (see adoption below):
1496
+ * the attempt never settled, so it holds no result for the cache to
1497
+ * overwrite. A reclaimed run reaching it is in the same position as
1498
+ * a fresh dispatch, and must be answered by a verified row another
1499
+ * run recorded under this key rather than by re-executing the body.
1500
+ * A crossing that already succeeded is the one exception: the row
1501
+ * carries this run's own result, which adoption settles from.
1502
+ *
1503
+ * Only cache-eligible declarations get here, which are sealed with a
1504
+ * hard boundary: an irreversible or compensable attempt, or a sealed
1505
+ * one without a boundary, adopts and re-executes exactly as before.
1506
+ * An unkeyed sealed step's key folds in its run, so no other run can
1507
+ * have recorded it, and its adoption re-executes as before too.
1508
+ */
1509
+ const orphaned = Option.isSome(existing) && existing.value.state === "running" &&
1510
+ decodeMeta(existing.value.meta)?.effectCrossing !== "succeeded"
1511
+ ? existing.value
1512
+ : undefined
1479
1513
  if (cacheDeclaration._tag === "Eligible") {
1480
1514
  const observed = yield* cache.get(keyDigest).pipe(
1481
1515
  Effect.catch((error) =>
@@ -1513,7 +1547,7 @@ export const make = (deps: Dependencies) => {
1513
1547
  }
1514
1548
  ))
1515
1549
  }
1516
- if (candidate._tag === "CandidateEvidence" && Option.isNone(existing)) {
1550
+ if (candidate._tag === "CandidateEvidence" && (Option.isNone(existing) || orphaned !== undefined)) {
1517
1551
  const boundary = yield* StepBoundary.StepBoundary
1518
1552
  // Skyframe's dirty check, not "the declaration changed" (issue
1519
1553
  // #90): the read-set digests folded into the step key are caller
@@ -1560,6 +1594,32 @@ export const make = (deps: Dependencies) => {
1560
1594
  recordedRunId: cached.value.recordedRunId,
1561
1595
  recordedEventSeq: cached.value.recordedEventSeq
1562
1596
  }))
1597
+ if (orphaned !== undefined) {
1598
+ // Close the orphaned attempt with the result that answered
1599
+ // it, so the run carries no attempt that started and never
1600
+ // finished, and a later re-drive replays this row. The
1601
+ // meta records no boundary evidence: this attempt measured
1602
+ // none of its own, so the row is never published back.
1603
+ const finishedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor))
1604
+ const settled = yield* settleAttempt({
1605
+ ...attemptId,
1606
+ state: "succeeded",
1607
+ finishedAtMs,
1608
+ outcome: attemptPayload(cached.value.result),
1609
+ meta: attemptPayload(
1610
+ {
1611
+ ...decodeMeta(orphaned.meta),
1612
+ ...declarationMeta,
1613
+ admittedBy: deps.owner
1614
+ } satisfies AttemptMeta
1615
+ )
1616
+ }, [
1617
+ JournalRecords.attemptFinished(attemptSource("finished"), { ...attemptId, state: "succeeded" })
1618
+ ])
1619
+ if (!settled) {
1620
+ return yield* Effect.interrupt
1621
+ }
1622
+ }
1563
1623
  // Counted after the provenance emit: `verified_hit` means
1564
1624
  // the cached result was served, and a journal failure on
1565
1625
  // the emit fails the dispatch before any result is
@@ -1953,7 +2013,8 @@ export const make = (deps: Dependencies) => {
1953
2013
  * is crash evidence, not a live admission (issue #71): the incarnation
1954
2014
  * that admitted the attempt died before finishing (SIGKILL, OOM), and
1955
2015
  * the #53 stale-running sweep re-drove the run to a new owner — or the
1956
- * same owner after restart. The attempt never completed, so it must
2016
+ * same owner after restart. The attempt never completed, so unless a
2017
+ * verified cache row answered it above (issue #3898), it must
1957
2018
  * re-execute under its original number rather than fall through to
1958
2019
  * `attempts.put` (whose `Conflict` on the differing `startedAtMs`
1959
2020
  * surfaced as `AttemptAdmissionRejected`, permanently failing a