@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
@@ -1017,7 +1017,18 @@ export const make = (deps) => {
1017
1017
  // permits and interleave the very span the permit serializes. The step
1018
1018
  // key, not the narrowed cache address, is what serializes here, because
1019
1019
  // the attempt rows two scopes of one key share are what they race over.
1020
- return yield* admission.withPermit(`${deps.runId}|${stepKeyDigest}`)(Effect.gen(function* () {
1020
+ //
1021
+ // A cache-eligible dispatch also holds the permit for its cache address,
1022
+ // taken second so every dispatch acquires the two in one order (issue
1023
+ // #3898). Runs share that address, so a second run asking for a result
1024
+ // this process is already computing waits for it and is answered by its
1025
+ // cache row instead of running the body beside it. That includes a run
1026
+ // the stale-running sweep reclaimed from a dead owner, whose in-flight
1027
+ // attempt would otherwise re-execute next to the run that superseded
1028
+ // it. Other processes are not excluded: two processes may still execute
1029
+ // one key at once, and the second recording resolves as before.
1030
+ const exclusive = (effect) => admission.withPermit(`run:${deps.runId}|${stepKeyDigest}`)(cacheDeclaration._tag === "Eligible" ? admission.withPermit(`cache:${keyDigest}`)(effect) : effect);
1031
+ return yield* exclusive(Effect.gen(function* () {
1021
1032
  // Lifecycle announcements take a per-attempt producer identity
1022
1033
  // (issue #91): adoption — or a replay after a crash in the
1023
1034
  // finish→emit window (issue #109) — re-emits records a dead
@@ -1223,6 +1234,26 @@ export const make = (deps) => {
1223
1234
  // A run's admitted attempt is replay authority. Shared cache rows
1224
1235
  // are replaceable and cannot overwrite its result or failure.
1225
1236
  const existing = yield* attempts.get(attemptId);
1237
+ /**
1238
+ * AN ORPHANED ATTEMPT ASKS THE CACHE FIRST (issue #3898). A `running`
1239
+ * row read under the permit is crash evidence (see adoption below):
1240
+ * the attempt never settled, so it holds no result for the cache to
1241
+ * overwrite. A reclaimed run reaching it is in the same position as
1242
+ * a fresh dispatch, and must be answered by a verified row another
1243
+ * run recorded under this key rather than by re-executing the body.
1244
+ * A crossing that already succeeded is the one exception: the row
1245
+ * carries this run's own result, which adoption settles from.
1246
+ *
1247
+ * Only cache-eligible declarations get here, which are sealed with a
1248
+ * hard boundary: an irreversible or compensable attempt, or a sealed
1249
+ * one without a boundary, adopts and re-executes exactly as before.
1250
+ * An unkeyed sealed step's key folds in its run, so no other run can
1251
+ * have recorded it, and its adoption re-executes as before too.
1252
+ */
1253
+ const orphaned = Option.isSome(existing) && existing.value.state === "running" &&
1254
+ decodeMeta(existing.value.meta)?.effectCrossing !== "succeeded"
1255
+ ? existing.value
1256
+ : undefined;
1226
1257
  if (cacheDeclaration._tag === "Eligible") {
1227
1258
  const observed = yield* cache.get(keyDigest).pipe(Effect.catch((error) => noteUnshareable({ stage: "entry", message: `cache lookup failed: ${error.message}` }).pipe(Effect.as(Option.none()))));
1228
1259
  const evidenceDecision = CacheAdmission.candidate(Option.isSome(observed) ? decodeMeta(observed.value.meta) : undefined);
@@ -1248,7 +1279,7 @@ export const make = (deps) => {
1248
1279
  recordedEventSeq: cached.value.recordedEventSeq
1249
1280
  }));
1250
1281
  }
1251
- if (candidate._tag === "CandidateEvidence" && Option.isNone(existing)) {
1282
+ if (candidate._tag === "CandidateEvidence" && (Option.isNone(existing) || orphaned !== undefined)) {
1252
1283
  const boundary = yield* StepBoundary.StepBoundary;
1253
1284
  // Skyframe's dirty check, not "the declaration changed" (issue
1254
1285
  // #90): the read-set digests folded into the step key are caller
@@ -1293,6 +1324,30 @@ export const make = (deps) => {
1293
1324
  recordedRunId: cached.value.recordedRunId,
1294
1325
  recordedEventSeq: cached.value.recordedEventSeq
1295
1326
  }));
1327
+ if (orphaned !== undefined) {
1328
+ // Close the orphaned attempt with the result that answered
1329
+ // it, so the run carries no attempt that started and never
1330
+ // finished, and a later re-drive replays this row. The
1331
+ // meta records no boundary evidence: this attempt measured
1332
+ // none of its own, so the row is never published back.
1333
+ const finishedAtMs = yield* Clock.currentTimeMillis.pipe(Effect.map(Math.floor));
1334
+ const settled = yield* settleAttempt({
1335
+ ...attemptId,
1336
+ state: "succeeded",
1337
+ finishedAtMs,
1338
+ outcome: attemptPayload(cached.value.result),
1339
+ meta: attemptPayload({
1340
+ ...decodeMeta(orphaned.meta),
1341
+ ...declarationMeta,
1342
+ admittedBy: deps.owner
1343
+ })
1344
+ }, [
1345
+ JournalRecords.attemptFinished(attemptSource("finished"), { ...attemptId, state: "succeeded" })
1346
+ ]);
1347
+ if (!settled) {
1348
+ return yield* Effect.interrupt;
1349
+ }
1350
+ }
1296
1351
  // Counted after the provenance emit: `verified_hit` means
1297
1352
  // the cached result was served, and a journal failure on
1298
1353
  // the emit fails the dispatch before any result is
@@ -1654,7 +1709,8 @@ export const make = (deps) => {
1654
1709
  * is crash evidence, not a live admission (issue #71): the incarnation
1655
1710
  * that admitted the attempt died before finishing (SIGKILL, OOM), and
1656
1711
  * the #53 stale-running sweep re-drove the run to a new owner — or the
1657
- * same owner after restart. The attempt never completed, so it must
1712
+ * same owner after restart. The attempt never completed, so unless a
1713
+ * verified cache row answered it above (issue #3898), it must
1658
1714
  * re-execute under its original number rather than fall through to
1659
1715
  * `attempts.put` (whose `Conflict` on the differing `startedAtMs`
1660
1716
  * surfaced as `AttemptAdmissionRejected`, permanently failing a