@remnic/core 9.51.1 → 9.52.1
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/dist/access-admin-ops-surface.d.ts +6 -5
- package/dist/access-admin-ops-surface.js +16 -16
- package/dist/access-authorization-probe.d.ts +6 -5
- package/dist/access-authorization-probe.js +17 -17
- package/dist/access-boundary.d.ts +6 -5
- package/dist/access-boundary.js +16 -16
- package/dist/access-cli.js +39 -39
- package/dist/access-extraction-force-flush.d.ts +6 -5
- package/dist/access-extraction-force-flush.js +16 -16
- package/dist/access-http-lcm-compaction.d.ts +6 -5
- package/dist/access-http-lifecycle-flush.d.ts +6 -5
- package/dist/access-http-offline-stream.d.ts +6 -5
- package/dist/access-http-query.js +16 -16
- package/dist/access-http.d.ts +6 -5
- package/dist/access-http.js +22 -22
- package/dist/access-identity-continuity-surface.d.ts +5 -4
- package/dist/access-identity-continuity-surface.js +16 -16
- package/dist/access-lcm-surface.d.ts +6 -5
- package/dist/access-lcm-surface.js +16 -16
- package/dist/access-mcp.d.ts +6 -5
- package/dist/access-mcp.js +20 -20
- package/dist/access-namespace-preflight.js +16 -16
- package/dist/access-observe-write-surface.d.ts +6 -5
- package/dist/access-observe-write-surface.js +16 -16
- package/dist/access-offline-manifest.d.ts +5 -4
- package/dist/access-operations-batch.js +17 -17
- package/dist/access-operations.d.ts +9 -8
- package/dist/access-operations.js +19 -19
- package/dist/access-recall-concurrency.d.ts +6 -5
- package/dist/access-recall-concurrency.js +16 -16
- package/dist/access-recall-response.d.ts +6 -5
- package/dist/access-recall-response.js +16 -16
- package/dist/access-recall-surface.d.ts +6 -5
- package/dist/access-recall-surface.js +16 -16
- package/dist/access-schema.d.ts +88 -88
- package/dist/{access-service-BY0KJ44c.d.ts → access-service-Cbk3bNbD.d.ts} +3 -3
- package/dist/access-service-helpers.d.ts +6 -5
- package/dist/access-service.d.ts +6 -5
- package/dist/access-service.js +16 -16
- package/dist/access-surface-catalog.d.ts +6 -5
- package/dist/access-wearables-meetings-surface.d.ts +2 -2
- package/dist/bootstrap.d.ts +5 -4
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +7 -7
- package/dist/buffer.d.ts +1 -1
- package/dist/{capsule-merge-XMFBXVDP.js → capsule-merge-HCJJMLPS.js} +4 -4
- package/dist/causal-consolidation.js +8 -8
- package/dist/{chunk-PWN32TAG.js → chunk-2ASAS3CQ.js} +2 -2
- package/dist/{chunk-WQM326GS.js → chunk-2LY6GIIE.js} +2 -2
- package/dist/{chunk-JL43BVDE.js → chunk-2OIQDTDC.js} +2 -2
- package/dist/{chunk-33SV3E43.js → chunk-3OY7KXHM.js} +3 -3
- package/dist/{chunk-AIUBBHSF.js → chunk-5UZ6UCOZ.js} +3 -3
- package/dist/chunk-5WITETWU.js +591 -0
- package/dist/chunk-5WITETWU.js.map +1 -0
- package/dist/{chunk-UT5BV5OG.js → chunk-7HXZJAID.js} +2 -2
- package/dist/{chunk-M7BXDKPR.js → chunk-7M6NYQWB.js} +3 -3
- package/dist/{chunk-TPTECFWR.js → chunk-B6LZ4IBH.js} +3 -3
- package/dist/{chunk-J5LLX6IL.js → chunk-BF6SXNQJ.js} +10 -10
- package/dist/{chunk-4PC4PRJE.js → chunk-BKIFTXHR.js} +2 -2
- package/dist/{chunk-VVG2BOKA.js → chunk-BMEYMKWX.js} +2 -2
- package/dist/{chunk-DCZBSE4F.js → chunk-D5YOZ66N.js} +18 -18
- package/dist/{chunk-BHXYAVUM.js → chunk-DLG5V6ND.js} +2 -2
- package/dist/{chunk-52UKFTUK.js → chunk-F4EU6VUC.js} +2 -2
- package/dist/{chunk-M4A44LUA.js → chunk-GSTIBXBW.js} +2 -2
- package/dist/{chunk-W5XVQFLM.js → chunk-GXFSQ6SD.js} +2 -2
- package/dist/{chunk-XADV5IZ3.js → chunk-HDIUYMSB.js} +5 -5
- package/dist/{chunk-FXTYIA46.js → chunk-J272WKX2.js} +2 -2
- package/dist/{chunk-ENTCVTQR.js → chunk-JJRVPWFY.js} +11 -1
- package/dist/chunk-JJRVPWFY.js.map +1 -0
- package/dist/{chunk-JYL3BVVZ.js → chunk-JSEJH2YQ.js} +2 -2
- package/dist/{chunk-5UDMFE3Q.js → chunk-MLXZD4WM.js} +2 -2
- package/dist/{chunk-PUSLMZBQ.js → chunk-NOEMGE56.js} +2 -2
- package/dist/{chunk-C5QLI33R.js → chunk-QINJITDS.js} +2 -2
- package/dist/{chunk-45BRYON6.js → chunk-QPOHMO6M.js} +2 -2
- package/dist/{chunk-ZQOCRIGA.js → chunk-QYN5PXDZ.js} +3 -3
- package/dist/{chunk-PA45XCUD.js → chunk-RANKJI5T.js} +2 -2
- package/dist/{chunk-ZHY5TMJG.js → chunk-RMHMUUBW.js} +2392 -628
- package/dist/chunk-RMHMUUBW.js.map +1 -0
- package/dist/{chunk-37ZO6LLJ.js → chunk-RYLEDKQ2.js} +2 -2
- package/dist/{chunk-CV6BBVYV.js → chunk-TEMSWHQD.js} +2 -2
- package/dist/{chunk-QGR6OVGT.js → chunk-VFSAWRMZ.js} +2 -2
- package/dist/{chunk-NLK2VZYZ.js → chunk-VXCNQYTC.js} +2 -2
- package/dist/{chunk-AZ2QBGYB.js → chunk-W7Y5JDVB.js} +1 -1
- package/dist/{chunk-VBHPOWBJ.js → chunk-WD5B5AIE.js} +1051 -591
- package/dist/chunk-WD5B5AIE.js.map +1 -0
- package/dist/{chunk-OYESWJ43.js → chunk-WLRD2TXF.js} +15 -10
- package/dist/chunk-WLRD2TXF.js.map +1 -0
- package/dist/{chunk-4F6X3RXW.js → chunk-X57M2Q4I.js} +2 -2
- package/dist/{chunk-6MTSFX7A.js → chunk-X7XEDOSL.js} +8 -8
- package/dist/{chunk-UEVLYVNW.js → chunk-YXKVLYXU.js} +2 -2
- package/dist/{chunk-OQAL6SIR.js → chunk-ZMSZVISA.js} +2 -2
- package/dist/{cli-CnOkH9hA.d.ts → cli-DMAFI0qt.d.ts} +3 -3
- package/dist/cli.d.ts +7 -6
- package/dist/cli.js +33 -33
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +7 -7
- package/dist/connectors/codex-materialize-runner.js +7 -7
- package/dist/connectors/index.js +7 -7
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-provenance-check.js +4 -4
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/consolidation-undo.js +3 -3
- package/dist/contradiction/index.d.ts +1 -1
- package/dist/convergence-refresh.d.ts +1 -1
- package/dist/convergence-refresh.js +9 -9
- package/dist/corpus-watermark.js +9 -9
- package/dist/dependency-propagation-delivery-D63r24pH.d.ts +41 -0
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +7 -7
- package/dist/explicit-capture.d.ts +5 -4
- package/dist/explicit-capture.js +4 -4
- package/dist/external-wiki-access.d.ts +6 -5
- package/dist/external-wiki-access.js +17 -17
- package/dist/external-wiki-mcp-tools.d.ts +6 -5
- package/dist/extraction.js +7 -7
- package/dist/{forget-UJDJHG6R.js → forget-HDS4N6CG.js} +3 -3
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +687 -686
- package/dist/index.js +39 -39
- package/dist/maintenance/memory-governance.js +7 -7
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
- package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
- package/dist/maintenance/rebuild-memory-projection.js +8 -8
- package/dist/{maintenance-CKwxpdRx.d.ts → maintenance-kBII2qUI.d.ts} +3 -2
- package/dist/mcp-memory-inspector-app.d.ts +6 -5
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +1 -1
- package/dist/namespaces/migrate.js +8 -8
- package/dist/namespaces/storage.d.ts +1 -1
- package/dist/namespaces/storage.js +7 -7
- package/dist/operator-doctor-corpus.js +10 -10
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +15 -15
- package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +12 -12
- package/dist/{orchestrator-BshGam-o.d.ts → orchestrator-DHy5LtTy.d.ts} +20 -35
- package/dist/orchestrator.d.ts +5 -4
- package/dist/orchestrator.js +39 -39
- package/dist/page-versioning.js +3 -3
- package/dist/schemas.d.ts +138 -138
- package/dist/semantic-consolidation.js +8 -8
- package/dist/semantic-rule-promotion.js +7 -7
- package/dist/semantic-rule-verifier.js +7 -7
- package/dist/{service-BIgVM7fL.d.ts → service-PT2_z7xm.d.ts} +1 -1
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/source-agent-qualifier.js +7 -7
- package/dist/{storage-CVfGWqCI.d.ts → storage-BmVudhk4.d.ts} +46 -27
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +6 -6
- package/dist/temporal-supersession.d.ts +51 -26
- package/dist/temporal-supersession.js +3 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/transfer/capsule-import.js +4 -4
- package/dist/transfer/types.d.ts +66 -66
- package/dist/verified-recall.js +7 -7
- package/package.json +2 -2
- package/src/lifecycle/tombstones.ts +30 -0
- package/src/orchestration/consolidation-run.ts +324 -106
- package/src/orchestration/contradiction-linking-coordinator.ts +237 -64
- package/src/orchestration/dependency-propagation-delivery.test.ts +2353 -0
- package/src/orchestration/dependency-propagation-delivery.ts +1143 -0
- package/src/orchestration/dependency-propagation-queue-state.ts +220 -0
- package/src/orchestration/dependency-propagation-recovery-replay.ts +237 -0
- package/src/orchestration/dependency-propagation-replay-reconciliation.ts +73 -0
- package/src/orchestration/dependency-propagation-triggers.test.ts +1069 -11
- package/src/orchestration/dependency-propagation.test.ts +82 -31
- package/src/orchestration/dependency-propagation.ts +55 -17
- package/src/orchestration/extraction-persist-deps.ts +2 -2
- package/src/orchestration/extraction-persist.ts +14 -15
- package/src/orchestration/extraction-queue-coordinator.ts +23 -1
- package/src/orchestration/extraction-shutdown.ts +29 -0
- package/src/orchestration/maintenance.ts +13 -7
- package/src/orchestration/turn-ingestion.ts +5 -1
- package/src/orchestrator-background-writes.test.ts +265 -1
- package/src/orchestrator-extraction-queue.test.ts +39 -0
- package/src/orchestrator.ts +59 -58
- package/src/storage/committed-invalidation.ts +34 -0
- package/src/storage/deletion-revision-store.ts +529 -0
- package/src/storage/supersession-audit.ts +41 -0
- package/src/storage/supersession-side-effects.ts +52 -25
- package/src/storage-contract/failure-semantics.test.ts +12 -0
- package/src/storage-contract/round-trip.test.ts +184 -2
- package/src/storage-deletion-revisions.test.ts +213 -1
- package/src/storage-frontmatter-cas.test.ts +141 -0
- package/src/storage-memory-lifecycle-ledger.test.ts +372 -0
- package/src/storage-replay.test.ts +149 -0
- package/src/storage.ts +345 -358
- package/src/temporal-supersession.test.ts +431 -2
- package/src/temporal-supersession.ts +567 -107
- package/src/testing/subjects/dependency-propagation.test.ts +179 -38
- package/dist/chunk-ENTCVTQR.js.map +0 -1
- package/dist/chunk-EWMTOVMY.js +0 -484
- package/dist/chunk-EWMTOVMY.js.map +0 -1
- package/dist/chunk-OYESWJ43.js.map +0 -1
- package/dist/chunk-VBHPOWBJ.js.map +0 -1
- package/dist/chunk-ZHY5TMJG.js.map +0 -1
- /package/dist/{capsule-merge-XMFBXVDP.js.map → capsule-merge-HCJJMLPS.js.map} +0 -0
- /package/dist/{chunk-PWN32TAG.js.map → chunk-2ASAS3CQ.js.map} +0 -0
- /package/dist/{chunk-WQM326GS.js.map → chunk-2LY6GIIE.js.map} +0 -0
- /package/dist/{chunk-JL43BVDE.js.map → chunk-2OIQDTDC.js.map} +0 -0
- /package/dist/{chunk-33SV3E43.js.map → chunk-3OY7KXHM.js.map} +0 -0
- /package/dist/{chunk-AIUBBHSF.js.map → chunk-5UZ6UCOZ.js.map} +0 -0
- /package/dist/{chunk-UT5BV5OG.js.map → chunk-7HXZJAID.js.map} +0 -0
- /package/dist/{chunk-M7BXDKPR.js.map → chunk-7M6NYQWB.js.map} +0 -0
- /package/dist/{chunk-TPTECFWR.js.map → chunk-B6LZ4IBH.js.map} +0 -0
- /package/dist/{chunk-J5LLX6IL.js.map → chunk-BF6SXNQJ.js.map} +0 -0
- /package/dist/{chunk-4PC4PRJE.js.map → chunk-BKIFTXHR.js.map} +0 -0
- /package/dist/{chunk-VVG2BOKA.js.map → chunk-BMEYMKWX.js.map} +0 -0
- /package/dist/{chunk-DCZBSE4F.js.map → chunk-D5YOZ66N.js.map} +0 -0
- /package/dist/{chunk-BHXYAVUM.js.map → chunk-DLG5V6ND.js.map} +0 -0
- /package/dist/{chunk-52UKFTUK.js.map → chunk-F4EU6VUC.js.map} +0 -0
- /package/dist/{chunk-M4A44LUA.js.map → chunk-GSTIBXBW.js.map} +0 -0
- /package/dist/{chunk-W5XVQFLM.js.map → chunk-GXFSQ6SD.js.map} +0 -0
- /package/dist/{chunk-XADV5IZ3.js.map → chunk-HDIUYMSB.js.map} +0 -0
- /package/dist/{chunk-FXTYIA46.js.map → chunk-J272WKX2.js.map} +0 -0
- /package/dist/{chunk-JYL3BVVZ.js.map → chunk-JSEJH2YQ.js.map} +0 -0
- /package/dist/{chunk-5UDMFE3Q.js.map → chunk-MLXZD4WM.js.map} +0 -0
- /package/dist/{chunk-PUSLMZBQ.js.map → chunk-NOEMGE56.js.map} +0 -0
- /package/dist/{chunk-C5QLI33R.js.map → chunk-QINJITDS.js.map} +0 -0
- /package/dist/{chunk-45BRYON6.js.map → chunk-QPOHMO6M.js.map} +0 -0
- /package/dist/{chunk-ZQOCRIGA.js.map → chunk-QYN5PXDZ.js.map} +0 -0
- /package/dist/{chunk-PA45XCUD.js.map → chunk-RANKJI5T.js.map} +0 -0
- /package/dist/{chunk-37ZO6LLJ.js.map → chunk-RYLEDKQ2.js.map} +0 -0
- /package/dist/{chunk-CV6BBVYV.js.map → chunk-TEMSWHQD.js.map} +0 -0
- /package/dist/{chunk-QGR6OVGT.js.map → chunk-VFSAWRMZ.js.map} +0 -0
- /package/dist/{chunk-NLK2VZYZ.js.map → chunk-VXCNQYTC.js.map} +0 -0
- /package/dist/{chunk-AZ2QBGYB.js.map → chunk-W7Y5JDVB.js.map} +0 -0
- /package/dist/{chunk-4F6X3RXW.js.map → chunk-X57M2Q4I.js.map} +0 -0
- /package/dist/{chunk-6MTSFX7A.js.map → chunk-X7XEDOSL.js.map} +0 -0
- /package/dist/{chunk-UEVLYVNW.js.map → chunk-YXKVLYXU.js.map} +0 -0
- /package/dist/{chunk-OQAL6SIR.js.map → chunk-ZMSZVISA.js.map} +0 -0
- /package/dist/{forget-UJDJHG6R.js.map → forget-HDS4N6CG.js.map} +0 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { ExtractionEngine } from "../extraction.js";
|
|
6
|
+
import { log } from "../logger.js";
|
|
7
|
+
import { resolveSafeStoragePath } from "../storage-paths.js";
|
|
8
|
+
import type { PluginConfig } from "../types.js";
|
|
9
|
+
import { listJsonFilesStrict, withJsonStoreMutationLock, writeJsonFileAtomic } from "../json-store.js";
|
|
10
|
+
import {
|
|
11
|
+
isDependencyPropagationEnabled,
|
|
12
|
+
propagateInvalidation,
|
|
13
|
+
type DependencyPropagationStorage,
|
|
14
|
+
type PropagationEvent,
|
|
15
|
+
} from "./dependency-propagation.js";
|
|
16
|
+
import {
|
|
17
|
+
canonicalEvent,
|
|
18
|
+
compareByteStable,
|
|
19
|
+
eventJobId,
|
|
20
|
+
isNotFound,
|
|
21
|
+
isPropagationEvent,
|
|
22
|
+
JOB_STATES,
|
|
23
|
+
validateJob,
|
|
24
|
+
} from "./dependency-propagation-queue-state.js";
|
|
25
|
+
import {
|
|
26
|
+
memoryFingerprint,
|
|
27
|
+
readCurrentReplacementEvent,
|
|
28
|
+
replayPrimaryMutation,
|
|
29
|
+
type DependencyPropagationRecoveryStorage,
|
|
30
|
+
} from "./dependency-propagation-recovery-replay.js";
|
|
31
|
+
export type DependencyPropagationJobStatus =
|
|
32
|
+
| "prepared"
|
|
33
|
+
| "ready"
|
|
34
|
+
| "leased"
|
|
35
|
+
| "retryable"
|
|
36
|
+
| "completed"
|
|
37
|
+
| "dead_letter"
|
|
38
|
+
| "canceled";
|
|
39
|
+
|
|
40
|
+
export interface DependencyPropagationJob {
|
|
41
|
+
jobId: string;
|
|
42
|
+
namespace: string;
|
|
43
|
+
sourceId: string;
|
|
44
|
+
event: PropagationEvent;
|
|
45
|
+
status: DependencyPropagationJobStatus;
|
|
46
|
+
attempts: number;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
updatedAt: number;
|
|
49
|
+
reservations?: number;
|
|
50
|
+
revision: number;
|
|
51
|
+
reservationIds?: string[];
|
|
52
|
+
nextAttemptAt?: number;
|
|
53
|
+
leaseOwner?: string;
|
|
54
|
+
leaseExpiresAt?: number;
|
|
55
|
+
lastError?: string;
|
|
56
|
+
preparedReplacementFingerprint?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface DependencyPropagationPreparationToken {
|
|
59
|
+
jobId: string;
|
|
60
|
+
revision: number;
|
|
61
|
+
ownsPreparedJob: boolean;
|
|
62
|
+
reservationId: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DependencyPropagationDeliveryPort {
|
|
66
|
+
prepare(event: PropagationEvent): Promise<DependencyPropagationPreparationToken | null>;
|
|
67
|
+
afterMutation(
|
|
68
|
+
token: DependencyPropagationPreparationToken | null,
|
|
69
|
+
event: PropagationEvent,
|
|
70
|
+
): Promise<void>;
|
|
71
|
+
cancel(token: DependencyPropagationPreparationToken | null): Promise<void>;
|
|
72
|
+
deferPrepared(token: DependencyPropagationPreparationToken | null): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Index reconciliation for a replayed primary mutation (recovery). Implemented
|
|
77
|
+
* by the host and injected via {@link DependencyPropagationDeliveryOptions} so
|
|
78
|
+
* the core delivery never statically imports optional index backends (rule 57).
|
|
79
|
+
*
|
|
80
|
+
* A normal consolidation pass reindexes the survivor and deindexes the
|
|
81
|
+
* superseded source inline after a MERGE/INVALIDATE. Recovery that replays the
|
|
82
|
+
* same primary mutation must perform the equivalent reconciliation BEFORE the
|
|
83
|
+
* job becomes ready; this port is the seam. A rejection keeps the job
|
|
84
|
+
* retryable rather than marking it ready (see recoverPrimaryMutation).
|
|
85
|
+
*/
|
|
86
|
+
export interface DependencyPropagationReplayReconciliationPort {
|
|
87
|
+
/** Deindex superseded memories — removes their stale index visibility. */
|
|
88
|
+
deindex(event: PropagationEvent, memoryIds: readonly string[]): Promise<void>;
|
|
89
|
+
/** Reindex the survivor — refreshes its index visibility. */
|
|
90
|
+
reindex(event: PropagationEvent, survivorId: string): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DependencyPropagationDeliveryOptions {
|
|
94
|
+
queueRoot: string;
|
|
95
|
+
config: PluginConfig;
|
|
96
|
+
extraction: ExtractionEngine;
|
|
97
|
+
getStorage: (namespace: string) => Promise<DependencyPropagationStorage>;
|
|
98
|
+
workerId?: string;
|
|
99
|
+
clock?: () => number;
|
|
100
|
+
retryDelayMs?: number;
|
|
101
|
+
leaseMs?: number;
|
|
102
|
+
maxAttempts?: number;
|
|
103
|
+
autoStart?: boolean;
|
|
104
|
+
schedule?: (run: () => Promise<void>, delayMs: number) => void;
|
|
105
|
+
readQueueFile?: (filePath: string) => Promise<string>;
|
|
106
|
+
writeQueueFile?: (filePath: string, content: string) => Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Index reconciliation invoked after a recovered primary-mutation replay,
|
|
109
|
+
* before the job becomes ready. Host-injected so the core never imports an
|
|
110
|
+
* index backend; absent it, recovery skips index reconciliation (back-compat).
|
|
111
|
+
* A rejection keeps the recovered job retryable rather than marking it ready.
|
|
112
|
+
*/
|
|
113
|
+
replayReconciliation?: DependencyPropagationReplayReconciliationPort;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type JobEntry = {
|
|
117
|
+
job: DependencyPropagationJob;
|
|
118
|
+
filePath: string;
|
|
119
|
+
};
|
|
120
|
+
const DEFAULT_RETRY_DELAY_MS = 1_000;
|
|
121
|
+
const DEFAULT_LEASE_MS = 60_000;
|
|
122
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
123
|
+
const TERMINAL_RETENTION_COUNT = 32;
|
|
124
|
+
const MAX_SERIALIZED_QUEUE_JOB_BYTES = 64 * 1024;
|
|
125
|
+
const MAX_STORED_ERROR_LENGTH = 1_024;
|
|
126
|
+
|
|
127
|
+
class QueueJobTooLargeError extends Error {
|
|
128
|
+
constructor(bytes: number) {
|
|
129
|
+
super(`dependency propagation queue job exceeds ${MAX_SERIALIZED_QUEUE_JOB_BYTES} bytes (${bytes})`);
|
|
130
|
+
this.name = "QueueJobTooLargeError";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatStoredError(error: unknown): string {
|
|
135
|
+
const text = String(error);
|
|
136
|
+
return text.length > MAX_STORED_ERROR_LENGTH ? text.slice(0, MAX_STORED_ERROR_LENGTH) : text;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function chooseEntry(entries: JobEntry[]): JobEntry | undefined {
|
|
140
|
+
return [...entries].sort((left, right) => {
|
|
141
|
+
if (left.job.revision !== right.job.revision) return right.job.revision - left.job.revision;
|
|
142
|
+
if (left.job.updatedAt !== right.job.updatedAt) return right.job.updatedAt - left.job.updatedAt;
|
|
143
|
+
return left.filePath < right.filePath ? -1 : left.filePath > right.filePath ? 1 : 0;
|
|
144
|
+
})[0];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class DependencyPropagationDelivery {
|
|
148
|
+
private readonly queueRoot: string;
|
|
149
|
+
private readonly getStorage: (namespace: string) => Promise<DependencyPropagationStorage>;
|
|
150
|
+
private readonly config: PluginConfig;
|
|
151
|
+
private readonly extraction: ExtractionEngine;
|
|
152
|
+
private readonly workerId: string;
|
|
153
|
+
private readonly clock: () => number;
|
|
154
|
+
private readonly retryDelayMs: number;
|
|
155
|
+
private readonly leaseMs: number;
|
|
156
|
+
private readonly maxAttempts: number;
|
|
157
|
+
private readonly autoStart: boolean;
|
|
158
|
+
private readonly schedule: (run: () => Promise<void>, delayMs: number) => void;
|
|
159
|
+
private readonly readQueueFile: (filePath: string) => Promise<string>;
|
|
160
|
+
private readonly writeQueueFile: (filePath: string, content: string) => Promise<void>;
|
|
161
|
+
private readonly replayReconciliation?: DependencyPropagationReplayReconciliationPort;
|
|
162
|
+
private backgroundScheduled = false;
|
|
163
|
+
private recoveryScheduled = false;
|
|
164
|
+
private backgroundRun: Promise<number> | null = null;
|
|
165
|
+
private readonly activeRuns = new Set<Promise<unknown>>();
|
|
166
|
+
private stopped = false;
|
|
167
|
+
|
|
168
|
+
constructor(options: DependencyPropagationDeliveryOptions) {
|
|
169
|
+
this.queueRoot = options.queueRoot;
|
|
170
|
+
this.config = options.config;
|
|
171
|
+
this.extraction = options.extraction;
|
|
172
|
+
this.getStorage = options.getStorage;
|
|
173
|
+
this.workerId = options.workerId ?? `worker-${process.pid}-${randomUUID()}`;
|
|
174
|
+
this.clock = options.clock ?? Date.now;
|
|
175
|
+
this.retryDelayMs = Math.max(0, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
|
|
176
|
+
this.leaseMs = Math.max(1, options.leaseMs ?? DEFAULT_LEASE_MS);
|
|
177
|
+
this.maxAttempts = Math.max(1, Math.floor(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS));
|
|
178
|
+
this.autoStart = options.autoStart ?? false;
|
|
179
|
+
this.schedule =
|
|
180
|
+
options.schedule ??
|
|
181
|
+
((run, delayMs) => {
|
|
182
|
+
const timer = setTimeout(() => {
|
|
183
|
+
void run().catch((error: unknown) => {
|
|
184
|
+
log.warn(`dependency propagation scheduled run failed: ${error}`);
|
|
185
|
+
});
|
|
186
|
+
}, delayMs);
|
|
187
|
+
timer.unref();
|
|
188
|
+
});
|
|
189
|
+
this.readQueueFile = options.readQueueFile ?? ((filePath) => readFile(filePath, "utf8"));
|
|
190
|
+
this.writeQueueFile =
|
|
191
|
+
options.writeQueueFile ??
|
|
192
|
+
((filePath, content) => writeJsonFileAtomic(filePath, JSON.parse(content) as unknown));
|
|
193
|
+
this.replayReconciliation = options.replayReconciliation;
|
|
194
|
+
}
|
|
195
|
+
async prepare(event: PropagationEvent): Promise<DependencyPropagationPreparationToken | null> {
|
|
196
|
+
if (this.stopped) return null;
|
|
197
|
+
if (!isDependencyPropagationEnabled(this.config)) return null;
|
|
198
|
+
const reservationId = randomUUID();
|
|
199
|
+
try {
|
|
200
|
+
if (!isPropagationEvent(event)) throw new Error("invalid dependency propagation event");
|
|
201
|
+
const snapshot = canonicalEvent(event);
|
|
202
|
+
const jobId = eventJobId(snapshot);
|
|
203
|
+
let preparedReplacementFingerprint: string | undefined;
|
|
204
|
+
if (snapshot.cause === "consolidation_merge" && snapshot.replacementId !== null) {
|
|
205
|
+
try {
|
|
206
|
+
const storage = await this.getStorage(snapshot.namespaceScope);
|
|
207
|
+
const replacement = await storage.getMemoryById(snapshot.replacementId);
|
|
208
|
+
if (!replacement) {
|
|
209
|
+
log.warn(
|
|
210
|
+
`dependency propagation survivor lookup returned no record for ${snapshot.replacementId}`,
|
|
211
|
+
);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
preparedReplacementFingerprint = memoryFingerprint(replacement);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// A transient survivor lookup failure must defer merge preparation.
|
|
217
|
+
// Do not persist a job that cannot prove its replacement fingerprint.
|
|
218
|
+
log.warn(`dependency propagation survivor fingerprint capture failed: ${error}`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
let token: DependencyPropagationPreparationToken | null = null;
|
|
223
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
224
|
+
const entries = await this.readEntries();
|
|
225
|
+
const existing = chooseEntry(entries.filter((entry) => entry.job.jobId === jobId));
|
|
226
|
+
if (existing && existing.job.status === "canceled") {
|
|
227
|
+
const reopened: DependencyPropagationJob = {
|
|
228
|
+
...existing.job,
|
|
229
|
+
status: "prepared",
|
|
230
|
+
attempts: 0,
|
|
231
|
+
reservations: 1,
|
|
232
|
+
reservationIds: [reservationId],
|
|
233
|
+
nextAttemptAt: undefined,
|
|
234
|
+
leaseOwner: undefined,
|
|
235
|
+
leaseExpiresAt: undefined,
|
|
236
|
+
lastError: undefined,
|
|
237
|
+
updatedAt: this.clock(),
|
|
238
|
+
revision: existing.job.revision + 1,
|
|
239
|
+
};
|
|
240
|
+
await this.writeQueueJob(await this.jobPath("prepared", jobId), reopened);
|
|
241
|
+
await unlink(existing.filePath).catch((error: unknown) => {
|
|
242
|
+
if (!isNotFound(error)) throw error;
|
|
243
|
+
});
|
|
244
|
+
token = {
|
|
245
|
+
jobId,
|
|
246
|
+
revision: reopened.revision,
|
|
247
|
+
ownsPreparedJob: true,
|
|
248
|
+
reservationId,
|
|
249
|
+
};
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (existing && existing.job.status === "prepared") {
|
|
253
|
+
const reservationIds = existing.job.reservationIds ?? [];
|
|
254
|
+
const next = {
|
|
255
|
+
...existing.job,
|
|
256
|
+
reservations: reservationIds.length + 1,
|
|
257
|
+
reservationIds: [...reservationIds, reservationId],
|
|
258
|
+
updatedAt: this.clock(),
|
|
259
|
+
};
|
|
260
|
+
await this.writeQueueJob(existing.filePath, next);
|
|
261
|
+
token = {
|
|
262
|
+
jobId,
|
|
263
|
+
revision: existing.job.revision,
|
|
264
|
+
ownsPreparedJob: true,
|
|
265
|
+
reservationId,
|
|
266
|
+
};
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (existing) {
|
|
270
|
+
token = {
|
|
271
|
+
jobId,
|
|
272
|
+
revision: existing.job.revision,
|
|
273
|
+
ownsPreparedJob: false,
|
|
274
|
+
reservationId,
|
|
275
|
+
};
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const now = this.clock();
|
|
279
|
+
const job: DependencyPropagationJob = {
|
|
280
|
+
jobId,
|
|
281
|
+
namespace: snapshot.namespaceScope,
|
|
282
|
+
sourceId: snapshot.oldMemory.frontmatter.id,
|
|
283
|
+
event: snapshot,
|
|
284
|
+
status: "prepared",
|
|
285
|
+
attempts: 0,
|
|
286
|
+
createdAt: now,
|
|
287
|
+
updatedAt: now,
|
|
288
|
+
reservations: 1,
|
|
289
|
+
reservationIds: [reservationId],
|
|
290
|
+
revision: 0,
|
|
291
|
+
...(preparedReplacementFingerprint ? { preparedReplacementFingerprint } : {}),
|
|
292
|
+
};
|
|
293
|
+
await this.writeQueueJob(await this.jobPath(job.status, job.jobId), job);
|
|
294
|
+
token = {
|
|
295
|
+
jobId,
|
|
296
|
+
revision: job.revision,
|
|
297
|
+
ownsPreparedJob: true,
|
|
298
|
+
reservationId,
|
|
299
|
+
};
|
|
300
|
+
});
|
|
301
|
+
return token;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
log.warn(`dependency propagation queue preparation failed: ${error}`);
|
|
304
|
+
if (error instanceof QueueJobTooLargeError) return null;
|
|
305
|
+
try {
|
|
306
|
+
if (isPropagationEvent(event)) {
|
|
307
|
+
const snapshot = canonicalEvent(event);
|
|
308
|
+
const jobId = eventJobId(snapshot);
|
|
309
|
+
return await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
310
|
+
const existing = chooseEntry(
|
|
311
|
+
(await this.readEntries()).filter((entry) => entry.job.jobId === jobId),
|
|
312
|
+
);
|
|
313
|
+
if (!existing || existing.job.status !== "prepared") return null;
|
|
314
|
+
const reservationIds = existing.job.reservationIds ?? [];
|
|
315
|
+
if (!reservationIds.includes(reservationId)) {
|
|
316
|
+
reservationIds.push(reservationId);
|
|
317
|
+
await this.writeQueueJob(existing.filePath, {
|
|
318
|
+
...existing.job,
|
|
319
|
+
reservationIds,
|
|
320
|
+
reservations: reservationIds.length,
|
|
321
|
+
updatedAt: this.clock(),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
jobId,
|
|
326
|
+
revision: existing.job.revision,
|
|
327
|
+
ownsPreparedJob: true,
|
|
328
|
+
reservationId,
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} catch (readbackError) {
|
|
333
|
+
log.warn(`dependency propagation queue preparation readback failed: ${readbackError}`);
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async afterMutation(
|
|
340
|
+
token: DependencyPropagationPreparationToken | null,
|
|
341
|
+
event: PropagationEvent,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
if (this.stopped) return;
|
|
344
|
+
if (token !== null) {
|
|
345
|
+
try {
|
|
346
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
347
|
+
const entries = await this.readEntries();
|
|
348
|
+
const matching = entries.filter((entry) => entry.job.jobId === token.jobId);
|
|
349
|
+
const current = chooseEntry(matching);
|
|
350
|
+
if (!current) throw new Error(`dependency propagation job ${token.jobId} is missing`);
|
|
351
|
+
if (current.job.status === "prepared" && current.job.revision === token.revision) {
|
|
352
|
+
const reservationIds = current.job.reservationIds;
|
|
353
|
+
if (reservationIds && !reservationIds.includes(token.reservationId)) return;
|
|
354
|
+
await this.transitionUnlocked(matching, current.job, "ready", {
|
|
355
|
+
...(reservationIds
|
|
356
|
+
? {
|
|
357
|
+
reservationIds: reservationIds.filter((id) => id !== token.reservationId),
|
|
358
|
+
reservations: Math.max(0, reservationIds.length - 1),
|
|
359
|
+
}
|
|
360
|
+
: {}),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
this.scheduleBackgroundRun();
|
|
365
|
+
return;
|
|
366
|
+
} catch (error) {
|
|
367
|
+
log.warn(`dependency propagation ready transition failed: ${error}`);
|
|
368
|
+
this.scheduleBackgroundRecovery();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await this.propagateDirect(event);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async cancel(token: DependencyPropagationPreparationToken | null): Promise<void> {
|
|
376
|
+
if (token === null) return;
|
|
377
|
+
try {
|
|
378
|
+
let terminalJob: DependencyPropagationJob | null = null;
|
|
379
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
380
|
+
const entries = await this.readEntries();
|
|
381
|
+
const matching = entries.filter((entry) => entry.job.jobId === token.jobId);
|
|
382
|
+
const current = chooseEntry(matching);
|
|
383
|
+
if (
|
|
384
|
+
!current ||
|
|
385
|
+
current.job.status !== "prepared" ||
|
|
386
|
+
current.job.revision !== token.revision
|
|
387
|
+
) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const reservationIds = current.job.reservationIds;
|
|
391
|
+
if (reservationIds && !reservationIds.includes(token.reservationId)) return;
|
|
392
|
+
if (reservationIds && reservationIds.length > 1) {
|
|
393
|
+
const remaining = reservationIds.filter((id) => id !== token.reservationId);
|
|
394
|
+
await this.writeQueueJob(current.filePath, {
|
|
395
|
+
...current.job,
|
|
396
|
+
reservationIds: remaining,
|
|
397
|
+
reservations: remaining.length,
|
|
398
|
+
updatedAt: this.clock(),
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
terminalJob = await this.transitionUnlocked(matching, current.job, "canceled", {
|
|
403
|
+
reservationIds: [],
|
|
404
|
+
reservations: 0,
|
|
405
|
+
lastError: undefined,
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
if (terminalJob !== null && !(await this.finalizeTerminalJob(terminalJob))) {
|
|
409
|
+
this.scheduleBackgroundRecovery();
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
log.warn(`dependency propagation cancellation failed for ${token.jobId}: ${error}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async deferPrepared(token: DependencyPropagationPreparationToken | null): Promise<void> {
|
|
417
|
+
if (this.stopped || token === null) return;
|
|
418
|
+
try {
|
|
419
|
+
const shouldSchedule = await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
420
|
+
const entries = await this.readEntries();
|
|
421
|
+
const current = chooseEntry(
|
|
422
|
+
entries.filter((entry) => entry.job.jobId === token.jobId),
|
|
423
|
+
);
|
|
424
|
+
if (
|
|
425
|
+
!current ||
|
|
426
|
+
current.job.status !== "prepared" ||
|
|
427
|
+
current.job.revision !== token.revision
|
|
428
|
+
) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const reservationIds = current.job.reservationIds;
|
|
432
|
+
return !reservationIds || reservationIds.includes(token.reservationId);
|
|
433
|
+
});
|
|
434
|
+
if (shouldSchedule) this.scheduleBackgroundRecovery();
|
|
435
|
+
} catch (error) {
|
|
436
|
+
log.warn(`dependency propagation prepared-job deferral failed for ${token.jobId}: ${error}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
private trackRun<T>(run: Promise<T>): Promise<T> {
|
|
440
|
+
this.activeRuns.add(run);
|
|
441
|
+
void run.finally(() => this.activeRuns.delete(run)).catch(() => undefined);
|
|
442
|
+
return run;
|
|
443
|
+
}
|
|
444
|
+
recover(): Promise<void> {
|
|
445
|
+
return this.trackRun(this.recoverInternal());
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async recoverInternal(): Promise<void> {
|
|
449
|
+
let jobs: DependencyPropagationJob[];
|
|
450
|
+
try {
|
|
451
|
+
jobs = await this.listJobs();
|
|
452
|
+
} catch (error) {
|
|
453
|
+
log.warn(`dependency propagation recovery scan failed: ${error}`);
|
|
454
|
+
this.scheduleBackgroundRecovery();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
let retryPreparedRecovery = false;
|
|
458
|
+
for (const job of jobs) {
|
|
459
|
+
if (
|
|
460
|
+
job.status === "completed" ||
|
|
461
|
+
job.status === "dead_letter" ||
|
|
462
|
+
job.status === "canceled"
|
|
463
|
+
) {
|
|
464
|
+
retryPreparedRecovery ||= !(await this.finalizeTerminalJob(job));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const now = this.clock();
|
|
468
|
+
// A prior recovery run may have claimed a prepared job, persisted a
|
|
469
|
+
// recovery-owned lease, and died BEFORE primary replay. Such a job sits in
|
|
470
|
+
// `leased` with an owner carrying the `:recovery:` segment (the only place
|
|
471
|
+
// that owner is ever written; both replay success → ready and replay
|
|
472
|
+
// failure → prepared clear it). Once that lease expires it MUST be routed
|
|
473
|
+
// back through primary replay before the background worker can claim it and
|
|
474
|
+
// deliver to dependents — otherwise dependent revalidation runs against a
|
|
475
|
+
// source whose primary mutation was never committed (#2326).
|
|
476
|
+
for (const job of jobs) {
|
|
477
|
+
if (
|
|
478
|
+
job.status !== "leased" ||
|
|
479
|
+
job.leaseOwner === undefined ||
|
|
480
|
+
!job.leaseOwner.includes(":recovery:") ||
|
|
481
|
+
(job.leaseExpiresAt ?? Number.POSITIVE_INFINITY) > now
|
|
482
|
+
)
|
|
483
|
+
continue;
|
|
484
|
+
let claimed: DependencyPropagationJob | null;
|
|
485
|
+
try {
|
|
486
|
+
claimed = await this.claimExpiredRecoveryLease(job);
|
|
487
|
+
} catch (error) {
|
|
488
|
+
retryPreparedRecovery = true;
|
|
489
|
+
log.warn(`dependency propagation expired recovery lease claim failed: ${error}`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (!claimed) continue;
|
|
493
|
+
const outcome = await this.recoverPrimaryMutation(claimed);
|
|
494
|
+
retryPreparedRecovery ||= outcome.retry;
|
|
495
|
+
}
|
|
496
|
+
for (const job of jobs) {
|
|
497
|
+
if (job.status !== "prepared") continue;
|
|
498
|
+
let claimed: DependencyPropagationJob | null;
|
|
499
|
+
try {
|
|
500
|
+
claimed = await this.claimPrepared(job);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
retryPreparedRecovery = true;
|
|
503
|
+
log.warn(`dependency propagation prepared-job claim failed: ${error}`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!claimed) continue;
|
|
507
|
+
const outcome = await this.recoverPrimaryMutation(claimed);
|
|
508
|
+
retryPreparedRecovery ||= outcome.retry;
|
|
509
|
+
}
|
|
510
|
+
if (retryPreparedRecovery) this.scheduleBackgroundRecovery();
|
|
511
|
+
await this.scheduleNextPendingRun();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async claimPrepared(job: DependencyPropagationJob): Promise<DependencyPropagationJob | null> {
|
|
515
|
+
return withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
516
|
+
const entries = await this.readEntries();
|
|
517
|
+
const matching = entries.filter((entry) => entry.job.jobId === job.jobId);
|
|
518
|
+
const current = chooseEntry(matching);
|
|
519
|
+
if (!current || current.job.status !== "prepared" || current.job.revision !== job.revision) {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
return this.transitionUnlocked(matching, current.job, "leased", {
|
|
523
|
+
leaseOwner: `${this.workerId}:recovery:${randomUUID()}`,
|
|
524
|
+
leaseExpiresAt: this.clock() + this.leaseMs,
|
|
525
|
+
nextAttemptAt: undefined,
|
|
526
|
+
lastError: undefined,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private async claimExpiredRecoveryLease(
|
|
532
|
+
job: DependencyPropagationJob,
|
|
533
|
+
): Promise<DependencyPropagationJob | null> {
|
|
534
|
+
return withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
535
|
+
const entries = await this.readEntries();
|
|
536
|
+
const matching = entries.filter((entry) => entry.job.jobId === job.jobId);
|
|
537
|
+
const current = chooseEntry(matching);
|
|
538
|
+
if (
|
|
539
|
+
!current ||
|
|
540
|
+
current.job.status !== "leased" ||
|
|
541
|
+
current.job.leaseOwner !== job.leaseOwner
|
|
542
|
+
) {
|
|
543
|
+
// The job advanced (another run reclaimed/replayed it) since the scan;
|
|
544
|
+
// never preempt a live owner — fencing.
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
// Re-check expiry against the live clock: a lease renewed between the
|
|
548
|
+
// scan and this claim still belongs to an in-flight run and must not be
|
|
549
|
+
// stolen.
|
|
550
|
+
if ((current.job.leaseExpiresAt ?? Number.POSITIVE_INFINITY) > this.clock()) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return this.transitionUnlocked(matching, current.job, "leased", {
|
|
554
|
+
leaseOwner: `${this.workerId}:recovery:${randomUUID()}`,
|
|
555
|
+
leaseExpiresAt: this.clock() + this.leaseMs,
|
|
556
|
+
nextAttemptAt: undefined,
|
|
557
|
+
lastError: undefined,
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async recoverPrimaryMutation(
|
|
563
|
+
claimed: DependencyPropagationJob,
|
|
564
|
+
): Promise<{ retry: boolean }> {
|
|
565
|
+
try {
|
|
566
|
+
const storage = await this.getStorage(claimed.event.namespaceScope);
|
|
567
|
+
const replayed = await this.withCurrentLease(claimed, () =>
|
|
568
|
+
replayPrimaryMutation(
|
|
569
|
+
storage,
|
|
570
|
+
claimed.event,
|
|
571
|
+
claimed.preparedReplacementFingerprint,
|
|
572
|
+
),
|
|
573
|
+
);
|
|
574
|
+
if (replayed !== true) {
|
|
575
|
+
return {
|
|
576
|
+
retry: await this.retainPreparedJob(
|
|
577
|
+
claimed,
|
|
578
|
+
new Error("primary mutation replay incomplete"),
|
|
579
|
+
),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (this.replayReconciliation) {
|
|
584
|
+
if (
|
|
585
|
+
claimed.event.cause === "consolidation_merge" &&
|
|
586
|
+
claimed.event.replacementId
|
|
587
|
+
) {
|
|
588
|
+
await this.replayReconciliation.reindex(
|
|
589
|
+
claimed.event,
|
|
590
|
+
claimed.event.replacementId,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
await this.replayReconciliation.deindex(
|
|
594
|
+
claimed.event,
|
|
595
|
+
[claimed.event.oldMemory.frontmatter.id],
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
599
|
+
const entries = await this.readEntries();
|
|
600
|
+
const matching = entries.filter((entry) => entry.job.jobId === claimed.jobId);
|
|
601
|
+
const current = chooseEntry(matching);
|
|
602
|
+
if (
|
|
603
|
+
current &&
|
|
604
|
+
current.job.status === "leased" &&
|
|
605
|
+
current.job.leaseOwner === claimed.leaseOwner &&
|
|
606
|
+
(current.job.leaseExpiresAt ?? Number.NEGATIVE_INFINITY) > this.clock()
|
|
607
|
+
) {
|
|
608
|
+
await this.transitionUnlocked(matching, current.job, "ready", {
|
|
609
|
+
leaseOwner: undefined,
|
|
610
|
+
leaseExpiresAt: undefined,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
return { retry: false };
|
|
615
|
+
} catch (error) {
|
|
616
|
+
log.warn(`dependency propagation prepared-job recovery failed: ${error}`);
|
|
617
|
+
return { retry: await this.retainPreparedJob(claimed, error) };
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
runUntilIdle(): Promise<number> {
|
|
622
|
+
return this.trackRun(this.runUntilIdleInternal());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private async runUntilIdleInternal(): Promise<number> {
|
|
626
|
+
let processed = 0;
|
|
627
|
+
while (true) {
|
|
628
|
+
let claimed: DependencyPropagationJob | null;
|
|
629
|
+
try {
|
|
630
|
+
claimed = await this.claimNext();
|
|
631
|
+
} catch (error) {
|
|
632
|
+
log.warn(`dependency propagation claim failed: ${error}`);
|
|
633
|
+
this.scheduleBackgroundRecovery();
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
if (!claimed) break;
|
|
637
|
+
processed += 1;
|
|
638
|
+
await this.processClaimed(claimed);
|
|
639
|
+
}
|
|
640
|
+
return processed;
|
|
641
|
+
}
|
|
642
|
+
private scheduleBackgroundRun(delayMs = 0): void {
|
|
643
|
+
if (!this.autoStart || this.stopped || this.backgroundScheduled) return;
|
|
644
|
+
this.backgroundScheduled = true;
|
|
645
|
+
this.schedule(async () => {
|
|
646
|
+
this.backgroundScheduled = false;
|
|
647
|
+
if (this.stopped) return;
|
|
648
|
+
if (this.backgroundRun !== null) {
|
|
649
|
+
await this.backgroundRun.catch(() => undefined);
|
|
650
|
+
this.scheduleBackgroundRun();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const run = this.runUntilIdle();
|
|
654
|
+
this.backgroundRun = run;
|
|
655
|
+
try {
|
|
656
|
+
await run;
|
|
657
|
+
} finally {
|
|
658
|
+
this.backgroundRun = null;
|
|
659
|
+
}
|
|
660
|
+
await this.scheduleNextPendingRun();
|
|
661
|
+
}, delayMs);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private scheduleBackgroundRecovery(delayMs = this.retryDelayMs): void {
|
|
665
|
+
if (!this.autoStart || this.stopped || this.recoveryScheduled) return;
|
|
666
|
+
this.recoveryScheduled = true;
|
|
667
|
+
this.schedule(async () => {
|
|
668
|
+
this.recoveryScheduled = false;
|
|
669
|
+
if (this.stopped) return;
|
|
670
|
+
await this.recover();
|
|
671
|
+
}, delayMs);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async scheduleNextPendingRun(): Promise<void> {
|
|
675
|
+
if (!this.autoStart || this.stopped || this.backgroundScheduled) return;
|
|
676
|
+
let jobs: DependencyPropagationJob[];
|
|
677
|
+
try {
|
|
678
|
+
jobs = await this.listJobs();
|
|
679
|
+
} catch (error) {
|
|
680
|
+
log.warn(`dependency propagation scheduling scan failed: ${error}`);
|
|
681
|
+
this.scheduleBackgroundRecovery();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const now = this.clock();
|
|
685
|
+
let nextDelayMs = Number.POSITIVE_INFINITY;
|
|
686
|
+
for (const job of jobs) {
|
|
687
|
+
if (job.status === "ready") {
|
|
688
|
+
nextDelayMs = 0;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
if (job.status === "retryable" && job.nextAttemptAt !== undefined) {
|
|
692
|
+
nextDelayMs = Math.min(nextDelayMs, Math.max(0, job.nextAttemptAt - now));
|
|
693
|
+
}
|
|
694
|
+
if (job.status === "leased" && job.leaseExpiresAt !== undefined) {
|
|
695
|
+
nextDelayMs = Math.min(nextDelayMs, Math.max(0, job.leaseExpiresAt - now));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (Number.isFinite(nextDelayMs)) this.scheduleBackgroundRun(nextDelayMs);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
async listJobs(): Promise<DependencyPropagationJob[]> {
|
|
703
|
+
const entries = await this.readEntries();
|
|
704
|
+
const grouped = new Map<string, JobEntry[]>();
|
|
705
|
+
for (const entry of entries) {
|
|
706
|
+
const group = grouped.get(entry.job.jobId) ?? [];
|
|
707
|
+
group.push(entry);
|
|
708
|
+
grouped.set(entry.job.jobId, group);
|
|
709
|
+
}
|
|
710
|
+
return [...grouped.values()]
|
|
711
|
+
.map((group) => chooseEntry(group)?.job)
|
|
712
|
+
.filter((job): job is DependencyPropagationJob => job !== undefined)
|
|
713
|
+
.sort((left, right) => compareByteStable(left.jobId, right.jobId));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async shutdown(): Promise<void> {
|
|
717
|
+
this.stopped = true;
|
|
718
|
+
while (true) {
|
|
719
|
+
const activeRuns = [...this.activeRuns];
|
|
720
|
+
if (activeRuns.length > 0) {
|
|
721
|
+
await Promise.all(activeRuns.map((run) => run.catch(() => 0)));
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (this.backgroundScheduled) {
|
|
725
|
+
this.backgroundScheduled = false;
|
|
726
|
+
await this.runUntilIdle();
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
for (let pass = 0; pass <= this.maxAttempts; pass += 1) {
|
|
733
|
+
await this.recover();
|
|
734
|
+
await this.runUntilIdle();
|
|
735
|
+
const jobs = await this.listJobs();
|
|
736
|
+
const now = this.clock();
|
|
737
|
+
const actionable = jobs.some(
|
|
738
|
+
(job) =>
|
|
739
|
+
job.status === "prepared" ||
|
|
740
|
+
job.status === "ready" ||
|
|
741
|
+
(job.status === "retryable" &&
|
|
742
|
+
(job.nextAttemptAt === undefined || job.nextAttemptAt <= now)) ||
|
|
743
|
+
(job.status === "leased" &&
|
|
744
|
+
job.leaseExpiresAt !== undefined &&
|
|
745
|
+
job.leaseExpiresAt <= now),
|
|
746
|
+
);
|
|
747
|
+
if (!actionable) return;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private async stateDir(status: DependencyPropagationJobStatus): Promise<string> {
|
|
752
|
+
return resolveSafeStoragePath(this.queueRoot, status);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private async jobPath(status: DependencyPropagationJobStatus, jobId: string): Promise<string> {
|
|
756
|
+
return resolveSafeStoragePath(this.queueRoot, status, `${jobId}.json`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private async writeQueueJob(filePath: string, job: DependencyPropagationJob): Promise<void> {
|
|
760
|
+
const serialized = JSON.stringify(job, null, 2);
|
|
761
|
+
const bytes = Buffer.byteLength(serialized, "utf8");
|
|
762
|
+
if (bytes > MAX_SERIALIZED_QUEUE_JOB_BYTES) throw new QueueJobTooLargeError(bytes);
|
|
763
|
+
await this.writeQueueFile(filePath, serialized);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private async readEntries(): Promise<JobEntry[]> {
|
|
767
|
+
const entries: JobEntry[] = [];
|
|
768
|
+
for (const status of JOB_STATES) {
|
|
769
|
+
let files: string[];
|
|
770
|
+
try {
|
|
771
|
+
files = await listJsonFilesStrict(await this.stateDir(status), { allowMissingDirectory: true });
|
|
772
|
+
} catch (error) {
|
|
773
|
+
log.warn(`dependency propagation state scan failed for ${status}: ${error}`);
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
for (const filePath of files) {
|
|
777
|
+
try {
|
|
778
|
+
const job = validateJob(JSON.parse(await this.readQueueFile(filePath)) as unknown, status);
|
|
779
|
+
if (!job) {
|
|
780
|
+
log.warn(`dependency propagation ignored invalid job file ${path.basename(filePath)}`);
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
entries.push({ job, filePath });
|
|
784
|
+
} catch (error) {
|
|
785
|
+
if (error instanceof SyntaxError || isNotFound(error)) {
|
|
786
|
+
log.warn(`dependency propagation ignored unreadable job file ${path.basename(filePath)}: ${error}`);
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return entries;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private async clearCommittedInvalidationProof(job: DependencyPropagationJob): Promise<boolean> {
|
|
797
|
+
if (
|
|
798
|
+
job.event.cause !== "consolidation_invalidate" &&
|
|
799
|
+
job.event.cause !== "consolidation_merge"
|
|
800
|
+
) {
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
const storage = (await this.getStorage(job.event.namespaceScope)) as DependencyPropagationRecoveryStorage;
|
|
805
|
+
if (!storage.clearCommittedInvalidation) {
|
|
806
|
+
log.warn("dependency propagation invalidation proof cleanup capability missing");
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
await storage.clearCommittedInvalidation(job.event.oldMemory);
|
|
810
|
+
return true;
|
|
811
|
+
} catch (error) {
|
|
812
|
+
log.warn(`dependency propagation invalidation proof cleanup failed: ${error}`);
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private async retainPreparedJob(job: DependencyPropagationJob, error: unknown): Promise<boolean> {
|
|
818
|
+
try {
|
|
819
|
+
let terminalJob: DependencyPropagationJob | null = null;
|
|
820
|
+
const retained = await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
821
|
+
const entries = await this.readEntries();
|
|
822
|
+
const matching = entries.filter((entry) => entry.job.jobId === job.jobId);
|
|
823
|
+
const current = chooseEntry(matching);
|
|
824
|
+
if (
|
|
825
|
+
!current ||
|
|
826
|
+
current.job.status !== "leased" ||
|
|
827
|
+
current.job.leaseOwner !== job.leaseOwner
|
|
828
|
+
) {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
const attempts = current.job.attempts + 1;
|
|
832
|
+
if (attempts >= this.maxAttempts) {
|
|
833
|
+
terminalJob = await this.transitionUnlocked(matching, current.job, "dead_letter", {
|
|
834
|
+
attempts,
|
|
835
|
+
lastError: formatStoredError(error),
|
|
836
|
+
});
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
await this.transitionUnlocked(matching, current.job, "prepared", {
|
|
840
|
+
attempts,
|
|
841
|
+
lastError: formatStoredError(error),
|
|
842
|
+
});
|
|
843
|
+
return true;
|
|
844
|
+
});
|
|
845
|
+
if (terminalJob !== null && !(await this.finalizeTerminalJob(terminalJob))) {
|
|
846
|
+
this.scheduleBackgroundRecovery();
|
|
847
|
+
}
|
|
848
|
+
return retained;
|
|
849
|
+
} catch (retainError) {
|
|
850
|
+
log.warn(`dependency propagation prepared-job retention failed: ${retainError}`);
|
|
851
|
+
return true;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async transitionUnlocked(
|
|
856
|
+
entries: JobEntry[],
|
|
857
|
+
job: DependencyPropagationJob,
|
|
858
|
+
status: DependencyPropagationJobStatus,
|
|
859
|
+
patch: Partial<DependencyPropagationJob> = {},
|
|
860
|
+
): Promise<DependencyPropagationJob> {
|
|
861
|
+
const next: DependencyPropagationJob = {
|
|
862
|
+
...job,
|
|
863
|
+
...patch,
|
|
864
|
+
status,
|
|
865
|
+
updatedAt: this.clock(),
|
|
866
|
+
revision: job.revision + 1,
|
|
867
|
+
};
|
|
868
|
+
const targetPath = await this.jobPath(status, next.jobId);
|
|
869
|
+
await this.writeQueueJob(targetPath, next);
|
|
870
|
+
const terminal =
|
|
871
|
+
status === "completed" || status === "dead_letter" || status === "canceled";
|
|
872
|
+
for (const entry of entries) {
|
|
873
|
+
if (path.resolve(entry.filePath) === path.resolve(targetPath)) continue;
|
|
874
|
+
await unlink(entry.filePath).catch((error: unknown) => {
|
|
875
|
+
if (isNotFound(error)) return;
|
|
876
|
+
if (terminal) {
|
|
877
|
+
log.warn(`dependency propagation old terminal state unlink failed: ${error}`);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
throw error;
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
return next;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private async finalizeTerminalJob(job: DependencyPropagationJob): Promise<boolean> {
|
|
887
|
+
if (!(await this.clearCommittedInvalidationProof(job))) return false;
|
|
888
|
+
try {
|
|
889
|
+
return await withJsonStoreMutationLock(this.queueRoot, () =>
|
|
890
|
+
this.pruneTerminalStatusUnlocked(job.status),
|
|
891
|
+
);
|
|
892
|
+
} catch (error) {
|
|
893
|
+
log.warn(`dependency propagation terminal finalization failed: ${error}`);
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
private async pruneTerminalStatusUnlocked(status: DependencyPropagationJobStatus): Promise<boolean> {
|
|
898
|
+
try {
|
|
899
|
+
const latestByJobId = new Map<string, JobEntry>();
|
|
900
|
+
for (const entry of (await this.readEntries()).filter((item) => item.job.status === status)) {
|
|
901
|
+
const current = latestByJobId.get(entry.job.jobId);
|
|
902
|
+
if (!current || chooseEntry([current, entry]) === entry) {
|
|
903
|
+
latestByJobId.set(entry.job.jobId, entry);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
const entries = [...latestByJobId.values()].sort((left, right) => {
|
|
907
|
+
if (left.job.updatedAt !== right.job.updatedAt) {
|
|
908
|
+
return right.job.updatedAt > left.job.updatedAt ? 1 : -1;
|
|
909
|
+
}
|
|
910
|
+
if (left.job.revision !== right.job.revision) {
|
|
911
|
+
return right.job.revision > left.job.revision ? 1 : -1;
|
|
912
|
+
}
|
|
913
|
+
return compareByteStable(left.filePath, right.filePath);
|
|
914
|
+
});
|
|
915
|
+
const oldJobs = entries.slice(TERMINAL_RETENTION_COUNT);
|
|
916
|
+
for (const entry of oldJobs) {
|
|
917
|
+
if (!(await this.clearCommittedInvalidationProof(entry.job))) return false;
|
|
918
|
+
}
|
|
919
|
+
let removed = true;
|
|
920
|
+
for (const entry of oldJobs) {
|
|
921
|
+
const jobRemoved = await this.removeAllQueueFilesForJobUnlocked(entry.job.jobId);
|
|
922
|
+
removed = jobRemoved && removed;
|
|
923
|
+
}
|
|
924
|
+
return removed;
|
|
925
|
+
} catch (error) {
|
|
926
|
+
log.warn(`dependency propagation terminal retention failed: ${error}`);
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private async removeAllQueueFilesForJobUnlocked(jobId: string): Promise<boolean> {
|
|
932
|
+
let removed = true;
|
|
933
|
+
for (const status of JOB_STATES) {
|
|
934
|
+
const files = await listJsonFilesStrict(await this.stateDir(status), {
|
|
935
|
+
allowMissingDirectory: true,
|
|
936
|
+
});
|
|
937
|
+
for (const filePath of files) {
|
|
938
|
+
if (path.basename(filePath, ".json") !== jobId) continue;
|
|
939
|
+
try {
|
|
940
|
+
await unlink(filePath);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (!isNotFound(error)) {
|
|
943
|
+
removed = false;
|
|
944
|
+
log.warn(`dependency propagation queue cleanup unlink failed: ${error}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return removed;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
private async claimNext(): Promise<DependencyPropagationJob | null> {
|
|
954
|
+
return withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
955
|
+
const now = this.clock();
|
|
956
|
+
const entries = await this.readEntries();
|
|
957
|
+
const candidates = new Map<string, JobEntry[]>();
|
|
958
|
+
for (const entry of entries) {
|
|
959
|
+
const group = candidates.get(entry.job.jobId) ?? [];
|
|
960
|
+
group.push(entry);
|
|
961
|
+
candidates.set(entry.job.jobId, group);
|
|
962
|
+
}
|
|
963
|
+
const jobs = [...candidates.values()]
|
|
964
|
+
.map((group) => ({ group, current: chooseEntry(group) }))
|
|
965
|
+
.filter((item): item is { group: JobEntry[]; current: JobEntry } => item.current !== undefined)
|
|
966
|
+
.sort((left, right) => compareByteStable(left.current.job.jobId, right.current.job.jobId));
|
|
967
|
+
for (const item of jobs) {
|
|
968
|
+
const current = item.current.job;
|
|
969
|
+
const expiredLease =
|
|
970
|
+
current.status === "leased" &&
|
|
971
|
+
(current.leaseExpiresAt ?? Number.POSITIVE_INFINITY) <= now;
|
|
972
|
+
const ready = current.status === "ready";
|
|
973
|
+
const retryable =
|
|
974
|
+
current.status === "retryable" &&
|
|
975
|
+
(current.nextAttemptAt ?? Number.POSITIVE_INFINITY) <= now;
|
|
976
|
+
if (!ready && !retryable && !expiredLease) continue;
|
|
977
|
+
return this.transitionUnlocked(item.group, current, "leased", {
|
|
978
|
+
attempts: current.attempts + 1,
|
|
979
|
+
leaseOwner: `${this.workerId}:${randomUUID()}`,
|
|
980
|
+
leaseExpiresAt: now + this.leaseMs,
|
|
981
|
+
nextAttemptAt: undefined,
|
|
982
|
+
lastError: undefined,
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
return null;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private async processClaimed(job: DependencyPropagationJob): Promise<void> {
|
|
990
|
+
let renewal = Promise.resolve();
|
|
991
|
+
const heartbeatMs = Math.max(1, Math.floor(this.leaseMs / 3));
|
|
992
|
+
const heartbeat = setInterval(() => {
|
|
993
|
+
renewal = renewal
|
|
994
|
+
.then(() => this.renewLease(job))
|
|
995
|
+
.catch((error: unknown) => {
|
|
996
|
+
log.warn(`dependency propagation lease renewal failed: ${error}`);
|
|
997
|
+
});
|
|
998
|
+
}, heartbeatMs);
|
|
999
|
+
heartbeat.unref();
|
|
1000
|
+
try {
|
|
1001
|
+
const storage = await this.getStorage(job.event.namespaceScope);
|
|
1002
|
+
const currentEvent = await readCurrentReplacementEvent(storage, job.event);
|
|
1003
|
+
if (currentEvent === null) {
|
|
1004
|
+
await this.finishAttempt(job, new Error("persisted replacement is missing"));
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const result = await propagateInvalidation(
|
|
1008
|
+
{
|
|
1009
|
+
storage,
|
|
1010
|
+
extraction: this.extraction,
|
|
1011
|
+
config: this.config,
|
|
1012
|
+
writeFence: <T>(write: () => Promise<T>) => this.withCurrentLease(job, write),
|
|
1013
|
+
},
|
|
1014
|
+
currentEvent,
|
|
1015
|
+
);
|
|
1016
|
+
const accountedDependents = result.invalidated + result.stillValid + result.uncertain;
|
|
1017
|
+
if (result.skipped === "timeout" || result.skipped === "llm_error") {
|
|
1018
|
+
await this.finishAttempt(job, new Error(`propagation skipped: ${result.skipped}`));
|
|
1019
|
+
} else if (accountedDependents < result.dependentsFound) {
|
|
1020
|
+
await this.finishAttempt(job, new Error("one or more dependent writes did not commit"));
|
|
1021
|
+
} else {
|
|
1022
|
+
await this.finishAttempt(job, null);
|
|
1023
|
+
}
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
await this.finishAttempt(job, error);
|
|
1026
|
+
} finally {
|
|
1027
|
+
clearInterval(heartbeat);
|
|
1028
|
+
await renewal;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private async withCurrentLease<T>(
|
|
1033
|
+
job: DependencyPropagationJob,
|
|
1034
|
+
write: () => Promise<T>,
|
|
1035
|
+
): Promise<T | undefined> {
|
|
1036
|
+
return withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
1037
|
+
const entries = await this.readEntries();
|
|
1038
|
+
const current = chooseEntry(entries.filter((entry) => entry.job.jobId === job.jobId));
|
|
1039
|
+
if (
|
|
1040
|
+
current?.job.status !== "leased" ||
|
|
1041
|
+
current.job.leaseOwner !== job.leaseOwner ||
|
|
1042
|
+
(current.job.leaseExpiresAt ?? Number.NEGATIVE_INFINITY) <= this.clock()
|
|
1043
|
+
) {
|
|
1044
|
+
return undefined;
|
|
1045
|
+
}
|
|
1046
|
+
const result = await write();
|
|
1047
|
+
const renewedEntries = await this.readEntries();
|
|
1048
|
+
const renewedMatching = renewedEntries.filter((entry) => entry.job.jobId === job.jobId);
|
|
1049
|
+
const renewedCurrent = chooseEntry(renewedMatching);
|
|
1050
|
+
if (
|
|
1051
|
+
renewedCurrent &&
|
|
1052
|
+
renewedCurrent.job.status === "leased" &&
|
|
1053
|
+
renewedCurrent.job.leaseOwner === job.leaseOwner
|
|
1054
|
+
) {
|
|
1055
|
+
await this.transitionUnlocked(renewedMatching, renewedCurrent.job, "leased", {
|
|
1056
|
+
leaseExpiresAt: this.clock() + this.leaseMs,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
return result;
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
private async renewLease(job: DependencyPropagationJob): Promise<void> {
|
|
1063
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
1064
|
+
const entries = await this.readEntries();
|
|
1065
|
+
const matching = entries.filter((entry) => entry.job.jobId === job.jobId);
|
|
1066
|
+
const current = chooseEntry(matching);
|
|
1067
|
+
if (
|
|
1068
|
+
!current ||
|
|
1069
|
+
current.job.status !== "leased" ||
|
|
1070
|
+
current.job.leaseOwner !== job.leaseOwner ||
|
|
1071
|
+
(current.job.leaseExpiresAt ?? Number.NEGATIVE_INFINITY) <= this.clock()
|
|
1072
|
+
) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
await this.transitionUnlocked(matching, current.job, "leased", {
|
|
1076
|
+
leaseExpiresAt: this.clock() + this.leaseMs,
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
private async finishAttempt(job: DependencyPropagationJob, error: unknown): Promise<void> {
|
|
1082
|
+
let retryDelayMs: number | null = null;
|
|
1083
|
+
let terminalJob: DependencyPropagationJob | null = null;
|
|
1084
|
+
try {
|
|
1085
|
+
await withJsonStoreMutationLock(this.queueRoot, async () => {
|
|
1086
|
+
const entries = await this.readEntries();
|
|
1087
|
+
const matching = entries.filter((entry) => entry.job.jobId === job.jobId);
|
|
1088
|
+
const current = chooseEntry(matching);
|
|
1089
|
+
if (
|
|
1090
|
+
!current ||
|
|
1091
|
+
current.job.status !== "leased" ||
|
|
1092
|
+
current.job.leaseOwner !== job.leaseOwner ||
|
|
1093
|
+
(current.job.leaseExpiresAt ?? Number.NEGATIVE_INFINITY) <= this.clock()
|
|
1094
|
+
) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (error === null) {
|
|
1098
|
+
terminalJob = await this.transitionUnlocked(matching, current.job, "completed", {
|
|
1099
|
+
leaseOwner: undefined,
|
|
1100
|
+
leaseExpiresAt: undefined,
|
|
1101
|
+
nextAttemptAt: undefined,
|
|
1102
|
+
lastError: undefined,
|
|
1103
|
+
});
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (current.job.attempts >= this.maxAttempts) {
|
|
1107
|
+
terminalJob = await this.transitionUnlocked(matching, current.job, "dead_letter", {
|
|
1108
|
+
leaseOwner: undefined,
|
|
1109
|
+
leaseExpiresAt: undefined,
|
|
1110
|
+
nextAttemptAt: undefined,
|
|
1111
|
+
lastError: formatStoredError(error),
|
|
1112
|
+
});
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
retryDelayMs = this.retryDelayMs * 2 ** Math.max(0, current.job.attempts - 1);
|
|
1116
|
+
await this.transitionUnlocked(matching, current.job, "retryable", {
|
|
1117
|
+
leaseOwner: undefined,
|
|
1118
|
+
leaseExpiresAt: undefined,
|
|
1119
|
+
nextAttemptAt: this.clock() + retryDelayMs,
|
|
1120
|
+
lastError: formatStoredError(error),
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
if (terminalJob !== null && !(await this.finalizeTerminalJob(terminalJob))) {
|
|
1124
|
+
this.scheduleBackgroundRecovery();
|
|
1125
|
+
}
|
|
1126
|
+
if (retryDelayMs !== null) this.scheduleBackgroundRun(retryDelayMs);
|
|
1127
|
+
} catch (finishError) {
|
|
1128
|
+
log.warn(`dependency propagation attempt finalization failed: ${finishError}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
private async propagateDirect(event: PropagationEvent): Promise<void> {
|
|
1133
|
+
try {
|
|
1134
|
+
const storage = await this.getStorage(event.namespaceScope);
|
|
1135
|
+
await propagateInvalidation(
|
|
1136
|
+
{ storage, extraction: this.extraction, config: this.config },
|
|
1137
|
+
event,
|
|
1138
|
+
);
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
log.warn(`dependency propagation direct fallback failed: ${error}`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|