@latysh-development/monolith 0.0.0-stage → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/brief/cli.d.ts +30 -0
- package/dist/brief/cli.d.ts.map +1 -0
- package/dist/brief/cli.js +85 -0
- package/dist/brief/collect.d.ts +17 -0
- package/dist/brief/collect.d.ts.map +1 -0
- package/dist/brief/collect.js +342 -0
- package/dist/brief/config.d.ts +40 -0
- package/dist/brief/config.d.ts.map +1 -0
- package/dist/brief/config.js +85 -0
- package/dist/brief/constants.d.ts +12 -0
- package/dist/brief/constants.d.ts.map +1 -0
- package/dist/brief/constants.js +11 -0
- package/dist/brief/docs.d.ts +53 -0
- package/dist/brief/docs.d.ts.map +1 -0
- package/dist/brief/docs.js +229 -0
- package/dist/brief/freshness.d.ts +104 -0
- package/dist/brief/freshness.d.ts.map +1 -0
- package/dist/brief/freshness.js +263 -0
- package/dist/brief/git-signals.d.ts +17 -0
- package/dist/brief/git-signals.d.ts.map +1 -0
- package/dist/brief/git-signals.js +101 -0
- package/dist/brief/index.d.ts +11 -0
- package/dist/brief/index.d.ts.map +1 -0
- package/dist/brief/index.js +5 -0
- package/dist/brief/reconcile.d.ts +75 -0
- package/dist/brief/reconcile.d.ts.map +1 -0
- package/dist/brief/reconcile.js +259 -0
- package/dist/brief/render.d.ts +67 -0
- package/dist/brief/render.d.ts.map +1 -0
- package/dist/brief/render.js +458 -0
- package/dist/brief/sanitize.d.ts +38 -0
- package/dist/brief/sanitize.d.ts.map +1 -0
- package/dist/brief/sanitize.js +48 -0
- package/dist/brief/selectors.d.ts +36 -0
- package/dist/brief/selectors.d.ts.map +1 -0
- package/dist/brief/selectors.js +75 -0
- package/dist/brief/story.d.ts +18 -0
- package/dist/brief/story.d.ts.map +1 -0
- package/dist/brief/story.js +59 -0
- package/dist/brief/types.d.ts +126 -0
- package/dist/brief/types.d.ts.map +1 -0
- package/dist/brief/types.js +11 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +199 -0
- package/dist/cli/init-args.d.ts +16 -0
- package/dist/cli/init-args.d.ts.map +1 -0
- package/dist/cli/init-args.js +24 -0
- package/dist/cli/ui-args.d.ts +23 -0
- package/dist/cli/ui-args.d.ts.map +1 -0
- package/dist/cli/ui-args.js +66 -0
- package/dist/cli/ui.d.ts +40 -0
- package/dist/cli/ui.d.ts.map +1 -0
- package/dist/cli/ui.js +180 -0
- package/dist/coordinatorium/checkpoint.d.ts +44 -0
- package/dist/coordinatorium/checkpoint.d.ts.map +1 -0
- package/dist/coordinatorium/checkpoint.js +124 -0
- package/dist/coordinatorium/cli.d.ts +39 -0
- package/dist/coordinatorium/cli.d.ts.map +1 -0
- package/dist/coordinatorium/cli.js +203 -0
- package/dist/coordinatorium/errors.d.ts +112 -0
- package/dist/coordinatorium/errors.d.ts.map +1 -0
- package/dist/coordinatorium/errors.js +145 -0
- package/dist/coordinatorium/index.d.ts +11 -0
- package/dist/coordinatorium/index.d.ts.map +1 -0
- package/dist/coordinatorium/index.js +5 -0
- package/dist/coordinatorium/lock.d.ts +187 -0
- package/dist/coordinatorium/lock.d.ts.map +1 -0
- package/dist/coordinatorium/lock.js +659 -0
- package/dist/coordinatorium/mission.d.ts +32 -0
- package/dist/coordinatorium/mission.d.ts.map +1 -0
- package/dist/coordinatorium/mission.js +87 -0
- package/dist/coordinatorium/state-keeper.d.ts +115 -0
- package/dist/coordinatorium/state-keeper.d.ts.map +1 -0
- package/dist/coordinatorium/state-keeper.js +396 -0
- package/dist/coordinatorium/types.d.ts +69 -0
- package/dist/coordinatorium/types.d.ts.map +1 -0
- package/dist/coordinatorium/types.js +27 -0
- package/dist/discovery/compose.d.ts +28 -0
- package/dist/discovery/compose.d.ts.map +1 -0
- package/dist/discovery/compose.js +159 -0
- package/dist/discovery/contracts.d.ts +211 -0
- package/dist/discovery/contracts.d.ts.map +1 -0
- package/dist/discovery/contracts.js +555 -0
- package/dist/discovery/docs.d.ts +278 -0
- package/dist/discovery/docs.d.ts.map +1 -0
- package/dist/discovery/docs.js +1182 -0
- package/dist/discovery/facts.d.ts +49 -0
- package/dist/discovery/facts.d.ts.map +1 -0
- package/dist/discovery/facts.js +541 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +9 -0
- package/dist/discovery/orchestrate.d.ts +35 -0
- package/dist/discovery/orchestrate.d.ts.map +1 -0
- package/dist/discovery/orchestrate.js +32 -0
- package/dist/discovery/render.d.ts +36 -0
- package/dist/discovery/render.d.ts.map +1 -0
- package/dist/discovery/render.js +278 -0
- package/dist/discovery/traverse.d.ts +197 -0
- package/dist/discovery/traverse.d.ts.map +1 -0
- package/dist/discovery/traverse.js +510 -0
- package/dist/durable/assess.d.ts +105 -0
- package/dist/durable/assess.d.ts.map +1 -0
- package/dist/durable/assess.js +239 -0
- package/dist/durable/canonical.d.ts +25 -0
- package/dist/durable/canonical.d.ts.map +1 -0
- package/dist/durable/canonical.js +78 -0
- package/dist/durable/cli.d.ts +37 -0
- package/dist/durable/cli.d.ts.map +1 -0
- package/dist/durable/cli.js +141 -0
- package/dist/durable/errors.d.ts +192 -0
- package/dist/durable/errors.d.ts.map +1 -0
- package/dist/durable/errors.js +323 -0
- package/dist/durable/fs-store.d.ts +254 -0
- package/dist/durable/fs-store.d.ts.map +1 -0
- package/dist/durable/fs-store.js +1493 -0
- package/dist/durable/index.d.ts +20 -0
- package/dist/durable/index.d.ts.map +1 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/log.d.ts +33 -0
- package/dist/durable/log.d.ts.map +1 -0
- package/dist/durable/log.js +157 -0
- package/dist/durable/memory-store.d.ts +51 -0
- package/dist/durable/memory-store.d.ts.map +1 -0
- package/dist/durable/memory-store.js +302 -0
- package/dist/durable/projection.d.ts +35 -0
- package/dist/durable/projection.d.ts.map +1 -0
- package/dist/durable/projection.js +328 -0
- package/dist/durable/prune-check.d.ts +34 -0
- package/dist/durable/prune-check.d.ts.map +1 -0
- package/dist/durable/prune-check.js +72 -0
- package/dist/durable/register.d.ts +100 -0
- package/dist/durable/register.d.ts.map +1 -0
- package/dist/durable/register.js +532 -0
- package/dist/durable/retention.d.ts +49 -0
- package/dist/durable/retention.d.ts.map +1 -0
- package/dist/durable/retention.js +59 -0
- package/dist/durable/runtime.d.ts +499 -0
- package/dist/durable/runtime.d.ts.map +1 -0
- package/dist/durable/runtime.js +1288 -0
- package/dist/durable/seams.d.ts +44 -0
- package/dist/durable/seams.d.ts.map +1 -0
- package/dist/durable/seams.js +21 -0
- package/dist/durable/testing.d.ts +11 -0
- package/dist/durable/testing.d.ts.map +1 -0
- package/dist/durable/testing.js +25 -0
- package/dist/durable/types.d.ts +442 -0
- package/dist/durable/types.d.ts.map +1 -0
- package/dist/durable/types.js +75 -0
- package/dist/graph/builder.d.ts +11 -0
- package/dist/graph/builder.d.ts.map +1 -0
- package/dist/graph/builder.js +153 -0
- package/dist/graph/identity.d.ts +37 -0
- package/dist/graph/identity.d.ts.map +1 -0
- package/dist/graph/identity.js +46 -0
- package/dist/graph/index.d.ts +7 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +3 -0
- package/dist/graph/resolver.d.ts +41 -0
- package/dist/graph/resolver.d.ts.map +1 -0
- package/dist/graph/resolver.js +36 -0
- package/dist/graph/types.d.ts +42 -0
- package/dist/graph/types.d.ts.map +1 -0
- package/dist/graph/types.js +1 -0
- package/dist/http/index.d.ts +5 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +2 -0
- package/dist/http/server.d.ts +76 -0
- package/dist/http/server.d.ts.map +1 -0
- package/dist/http/server.js +195 -0
- package/dist/http/ui-assets.d.ts +22 -0
- package/dist/http/ui-assets.d.ts.map +1 -0
- package/dist/http/ui-assets.js +100 -0
- package/dist/ideas/cli.d.ts +33 -0
- package/dist/ideas/cli.d.ts.map +1 -0
- package/dist/ideas/cli.js +158 -0
- package/dist/ideas/format.d.ts +38 -0
- package/dist/ideas/format.d.ts.map +1 -0
- package/dist/ideas/format.js +194 -0
- package/dist/ideas/index.d.ts +6 -0
- package/dist/ideas/index.d.ts.map +1 -0
- package/dist/ideas/index.js +3 -0
- package/dist/ideas/store.d.ts +55 -0
- package/dist/ideas/store.d.ts.map +1 -0
- package/dist/ideas/store.js +262 -0
- package/dist/ideas/types.d.ts +54 -0
- package/dist/ideas/types.d.ts.map +1 -0
- package/dist/ideas/types.js +26 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/init/index.d.ts +12 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +90 -0
- package/dist/init/prompts.d.ts +3 -0
- package/dist/init/prompts.d.ts.map +1 -0
- package/dist/init/prompts.js +47 -0
- package/dist/init/scaffold.d.ts +29 -0
- package/dist/init/scaffold.d.ts.map +1 -0
- package/dist/init/scaffold.js +208 -0
- package/dist/init/types.d.ts +14 -0
- package/dist/init/types.d.ts.map +1 -0
- package/dist/init/types.js +1 -0
- package/dist/managed/index.d.ts +73 -0
- package/dist/managed/index.d.ts.map +1 -0
- package/dist/managed/index.js +222 -0
- package/dist/readmodel/dashboard.d.ts +40 -0
- package/dist/readmodel/dashboard.d.ts.map +1 -0
- package/dist/readmodel/dashboard.js +236 -0
- package/dist/readmodel/index.d.ts +3 -0
- package/dist/readmodel/index.d.ts.map +1 -0
- package/dist/readmodel/index.js +1 -0
- package/dist/readmodel/types.d.ts +104 -0
- package/dist/readmodel/types.d.ts.map +1 -0
- package/dist/readmodel/types.js +15 -0
- package/dist/root/index.d.ts +186 -0
- package/dist/root/index.d.ts.map +1 -0
- package/dist/root/index.js +430 -0
- package/dist/root/safe-write.d.ts +268 -0
- package/dist/root/safe-write.d.ts.map +1 -0
- package/dist/root/safe-write.js +966 -0
- package/dist/root/write-artifacts.d.ts +20 -0
- package/dist/root/write-artifacts.d.ts.map +1 -0
- package/dist/root/write-artifacts.js +42 -0
- package/dist/scriptorium/compile.d.ts +4 -0
- package/dist/scriptorium/compile.d.ts.map +1 -0
- package/dist/scriptorium/compile.js +185 -0
- package/dist/scriptorium/frontmatter.d.ts +13 -0
- package/dist/scriptorium/frontmatter.d.ts.map +1 -0
- package/dist/scriptorium/frontmatter.js +90 -0
- package/dist/scriptorium/index.d.ts +3 -0
- package/dist/scriptorium/index.d.ts.map +1 -0
- package/dist/scriptorium/index.js +1 -0
- package/dist/scriptorium/markdown.d.ts +31 -0
- package/dist/scriptorium/markdown.d.ts.map +1 -0
- package/dist/scriptorium/markdown.js +88 -0
- package/dist/scriptorium/types.d.ts +78 -0
- package/dist/scriptorium/types.d.ts.map +1 -0
- package/dist/scriptorium/types.js +1 -0
- package/dist/types/enforcement.d.ts +11 -0
- package/dist/types/enforcement.d.ts.map +1 -0
- package/dist/types/enforcement.js +1 -0
- package/dist/types/integrity.d.ts +33 -0
- package/dist/types/integrity.d.ts.map +1 -0
- package/dist/types/integrity.js +1 -0
- package/dist/types/project-definition.d.ts +30 -0
- package/dist/types/project-definition.d.ts.map +1 -0
- package/dist/types/project-definition.js +1 -0
- package/dist/ui/assets/cosmic-glass-bg-Dwx47zPu.webp +0 -0
- package/dist/ui/assets/index-C4NhMaq7.js +28 -0
- package/dist/ui/assets/index-DMZ7ZvZY.css +1 -0
- package/dist/ui/index.html +13 -0
- package/dist/update/cli.d.ts +8 -0
- package/dist/update/cli.d.ts.map +1 -0
- package/dist/update/cli.js +115 -0
- package/dist/update/index.d.ts +78 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +285 -0
- package/dist/validate/index.d.ts +6 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +214 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +1 -0
- package/package.json +67 -4
- package/templates/.gitkeep +0 -0
- package/templates/default-instance/instruction/.cursorrules +59 -0
- package/templates/default-instance/instruction/.windsurfrules +59 -0
- package/templates/default-instance/instruction/CLAUDE.md +57 -0
- package/templates/default-instance/managed/agent.md +20 -0
- package/templates/default-instance/managed/bootstrap.md +7 -0
- package/templates/default-instance/managed/index-idea.md +5 -0
- package/templates/default-instance/managed/routing-idea.md +14 -0
- package/templates/default-instance/monolith/AGENT.md +1 -0
- package/templates/default-instance/monolith/context/boot/HANDOFF.md +49 -0
- package/templates/default-instance/monolith/context/boot/WORK_SESSION.md +30 -0
- package/templates/default-instance/monolith/context/execution/current-state.md +30 -0
- package/templates/default-instance/monolith/context/maps/dependency-map.md +38 -0
- package/templates/default-instance/monolith/context/maps/project-map.md +85 -0
- package/templates/default-instance/monolith/context/maps/roadmap-index.md +41 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +151 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +75 -0
- package/templates/default-instance/monolith/context/models/model-registry.md +37 -0
- package/templates/default-instance/monolith/context/models/privacy-classification.md +35 -0
- package/templates/default-instance/monolith/context/templates/plan-template.md +56 -0
- package/templates/default-instance/monolith/governance/README.md +29 -0
- package/templates/default-instance/monolith/governance/RULE_SCHEMA.md +58 -0
- package/templates/default-instance/monolith/learning/INDEX.md +48 -0
- package/templates/default-instance/monolith/learning/injection-map.md +49 -0
- package/templates/default-instance/monolith/learning/taxonomy.md +30 -0
- package/templates/default-instance/monolith/memory/errors/README.md +26 -0
- package/templates/default-instance/monolith/memory/errors/SCHEMA.md +47 -0
- package/templates/default-instance/monolith/memory/errors/TAXONOMY.md +27 -0
- package/templates/default-instance/monolith/memory/long-term/architecture-principles.md +31 -0
- package/templates/default-instance/monolith/memory/long-term/decisions.md +36 -0
- package/templates/default-instance/monolith/memory/long-term/execution-history.md +20 -0
- package/templates/default-instance/monolith/memory/long-term/tech-debt.md +25 -0
- package/templates/default-instance/monolith/memory/working/active.md +41 -0
- package/templates/default-instance/monolith/memory/working/governance-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/integrity-result.md +0 -0
- package/templates/default-instance/monolith/memory/working/story-index.md +28 -0
- package/templates/default-instance/monolith/protocols/boot/boot-sequence.md +59 -0
- package/templates/default-instance/monolith/protocols/cognition/cognitive-modes.md +90 -0
- package/templates/default-instance/monolith/protocols/cognition/dry-run.md +56 -0
- package/templates/default-instance/monolith/protocols/cognition/execution-trace-format.md +52 -0
- package/templates/default-instance/monolith/protocols/cognition/semantic-planning.md +51 -0
- package/templates/default-instance/monolith/protocols/execution/hybrid-execution.md +37 -0
- package/templates/default-instance/monolith/protocols/execution/local-llm-governance.md +42 -0
- package/templates/default-instance/monolith/protocols/governance/command-policy.md +51 -0
- package/templates/default-instance/monolith/protocols/governance/core-invariants.md +40 -0
- package/templates/default-instance/monolith/protocols/governance/memory-governance.md +54 -0
- package/templates/default-instance/monolith/protocols/governance/metadata-schema.md +62 -0
- package/templates/default-instance/monolith/protocols/governance/skills.md +53 -0
- package/templates/default-instance/monolith/protocols/governance/work-session.md +46 -0
- package/templates/default-instance/monolith/skills/INDEX.md +32 -0
- package/templates/default-instance/monolith/skills/current-status/SKILL.md +47 -0
- package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +86 -0
- package/templates/default-instance/monolith/skills/global-status/SKILL.md +56 -0
- package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
- package/templates/default-instance/monolith/skills/self-improvement/SKILL.md +77 -0
- package/templates/default-instance/monolith/state.json +11 -0
- package/templates/default-instance/monolith/tests/INDEX.md +57 -0
- package/templates/default-instance/monolith/tests/scenarios/architecture-review.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/debug-failure.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/dry-run-activation.md +33 -0
- package/templates/default-instance/monolith/tests/scenarios/execution-trace-verification.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/finalize-story.md +35 -0
- package/templates/default-instance/monolith/tests/scenarios/implement-feature.md +36 -0
- package/templates/default-instance/monolith/tests/scenarios/local-llm-delegation.md +31 -0
- package/templates/default-instance/monolith/tests/scenarios/planning-request.md +32 -0
- package/templates/default-instance/monolith/tests/scenarios/sensitive-context-task.md +30 -0
- package/templates/default-instance/monolith/tests/scenarios/validator-verification.md +33 -0
- package/templates/default-instance/monolith/tests/validation/expected-behavior.md +70 -0
- package/templates/default-instance/monolith/tests/validators/INDEX.md +17 -0
- package/templates/default-instance/monolith/tests/validators/delegation-semantics.md +22 -0
- package/templates/default-instance/monolith/tests/validators/mode-consistency.md +25 -0
- package/templates/default-instance/monolith/tests/validators/privacy-routing.md +23 -0
- package/templates/default-instance/monolith/tests/validators/protocol-references.md +27 -0
- package/templates/default-instance/monolith/tests/validators/routing-integrity.md +30 -0
- package/templates/default-instance/projectroot/AGENTS.md +111 -0
- package/templates/default-instance/projectroot/monolith.config.json +32 -0
- package/templates/default-instance/projectroot/project.definition.mjs.template +41 -0
|
@@ -0,0 +1,1493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem `EventStore`.
|
|
3
|
+
*
|
|
4
|
+
* Layout under the store directory:
|
|
5
|
+
*
|
|
6
|
+
* tasks/<taskId>/events/<seq,9 digits>.json one canonical event, immutable
|
|
7
|
+
* tasks/<taskId>/segments/S<from>-<to>.jsonl packed, verified events (compaction)
|
|
8
|
+
* tasks/<taskId>/prune-<n>.prepare.json prune decision register (ADR-019): one attempt per file pair,
|
|
9
|
+
* tasks/<taskId>/prune-<n>.decision.json create-only, never deleted, never rewritten
|
|
10
|
+
* tasks/<taskId>/tombstone.json published from the `commit` record of the register
|
|
11
|
+
* durable-protocol.json version marker; created only by `initializeProtocol()`
|
|
12
|
+
*
|
|
13
|
+
* Concurrency needs no lock file. The one linearizable primitive is "create
|
|
14
|
+
* `<seq>.json` if it does not exist" — `createFile` of the safe-write
|
|
15
|
+
* primitive (temp file, fsync, `link()`, ADR-012). Of N writers racing for the
|
|
16
|
+
* same slot exactly one wins; the others see `conflict`, re-read and retry.
|
|
17
|
+
* There is no owner to declare dead, so none of the stale-lock failure modes
|
|
18
|
+
* (PID reuse across namespaces, wedged lock after a crash; ADR-015) exist.
|
|
19
|
+
*
|
|
20
|
+
* Crash behaviour:
|
|
21
|
+
* - before `link()` : nothing is visible; the temp file is a stranger.
|
|
22
|
+
* - between `link()`/`unlink` : the event is complete and visible, but the file
|
|
23
|
+
* has two names (`nlink == 2`). The strict reader of ADR-014 refuses such a
|
|
24
|
+
* file, which would wedge the task for good. Here that state is *provable
|
|
25
|
+
* residue* — the second name is `<file>.tmp.<pid>.<rand>` with the same
|
|
26
|
+
* dev/ino — and the reader removes exactly that name and re-reads. Any
|
|
27
|
+
* other second link stays a hard `StoreSecurityError`.
|
|
28
|
+
* - compaction / prune : ordered so that every prefix of the steps is a
|
|
29
|
+
* consistent state (segment before unlink, tombstone before removal).
|
|
30
|
+
*
|
|
31
|
+
* Out of scope (same as the rest of MONOLITH, ADR-013): a local adversary that
|
|
32
|
+
* swaps directories concurrently (TM-C), NFS/SMB, Windows.
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from 'fs';
|
|
35
|
+
import { dirname, join } from 'path';
|
|
36
|
+
import { sha256Hex } from './canonical.js';
|
|
37
|
+
import { LogCorruptError, PruneAttemptLimitError, PruneDecisionCorruptError, RetentionRefusedError, StoreIoError, StoreProtocolError, StoreSecurityError, TaskPrunedError, TombstoneCommittedError, } from './errors.js';
|
|
38
|
+
import { MAX_REGISTER_BYTES, MAX_WRITE_RESOLVE, PROTOCOL_MARKER_FILE, STAGING_RE, TOMBSTONE_FILE, decisionFile, derivePruneState, encodeDecision, encodePrepare, encodeTombstone, parseDecision, parsePrepare, parseTombstoneFile, prepareFile, sameTombstone, } from './register.js';
|
|
39
|
+
import { committedProblem, tombstoneProblem } from './prune-check.js';
|
|
40
|
+
import { takeFsSeams } from './seams.js';
|
|
41
|
+
import { MAX_EVENT_BYTES, ID_PATTERN, parseEvent, serializeEvent } from './log.js';
|
|
42
|
+
import { MAX_PRUNE_ATTEMPTS } from './types.js';
|
|
43
|
+
import { SafeWriteError, createFile, openParent, projectBoundary, readBoundedFile, trustedDirectoryBoundary, } from '../root/index.js';
|
|
44
|
+
/** Packed segments stay well below this; it is the reader's ceiling. */
|
|
45
|
+
export const MAX_SEGMENT_BYTES = 16 * 1024 * 1024;
|
|
46
|
+
const EVENT_NAME = /^(\d{9})\.json$/;
|
|
47
|
+
const SEGMENT_NAME = /^S(\d{9})-(\d{9})\.jsonl$/;
|
|
48
|
+
const REF_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}\.json$/;
|
|
49
|
+
const TMP_NAME = STAGING_RE;
|
|
50
|
+
const TOMBSTONE_NAME = TOMBSTONE_FILE;
|
|
51
|
+
/** how often the audit of a committed prune looks at a log with a gap and no tombstone before it refuses (I-1). */
|
|
52
|
+
const AUDIT_PASSES = 3;
|
|
53
|
+
const MAX_TOMBSTONE_BYTES = 4096;
|
|
54
|
+
const READ_ATTEMPTS = 8;
|
|
55
|
+
export class AppendOutcomeUnknownError extends StoreIoError {
|
|
56
|
+
taskId;
|
|
57
|
+
seq;
|
|
58
|
+
constructor(taskId, seq, cause) {
|
|
59
|
+
super(`publication of event ${seq} of task "${taskId}" has an unknown outcome; re-read the log to find out.`, cause);
|
|
60
|
+
this.taskId = taskId;
|
|
61
|
+
this.seq = seq;
|
|
62
|
+
this.name = 'AppendOutcomeUnknownError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const SECURITY_WRITE_CODES = new Set([
|
|
66
|
+
'WRITE_BOUNDARY_INVALID',
|
|
67
|
+
'WRITE_OUTSIDE_BOUNDARY',
|
|
68
|
+
'WRITE_PARENT_SYMLINK',
|
|
69
|
+
'WRITE_PARENT_NOT_DIRECTORY',
|
|
70
|
+
'WRITE_PARENT_CHANGED',
|
|
71
|
+
'WRITE_TARGET_SYMLINK',
|
|
72
|
+
'WRITE_TARGET_NOT_REGULAR',
|
|
73
|
+
'WRITE_TARGET_HARDLINKED',
|
|
74
|
+
]);
|
|
75
|
+
const SECURITY_REASONS = new Set(['FILE_SYMLINK', 'FILE_OUTSIDE_PROJECT', 'FILE_NOT_REGULAR', 'READ_BOUNDARY_INVALID', 'FILE_HARDLINKED']);
|
|
76
|
+
const SECURITY_READ_CODES = new Set(['FILE_OUTSIDE_PROJECT', 'FILE_SYMLINK', 'FILE_NOT_REGULAR', 'READ_BOUNDARY_INVALID', 'READ_UNSUPPORTED_PLATFORM']);
|
|
77
|
+
function errno(err) {
|
|
78
|
+
return err?.code;
|
|
79
|
+
}
|
|
80
|
+
function sleepMs(ms) {
|
|
81
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
82
|
+
}
|
|
83
|
+
const pad = (n) => String(n).padStart(9, '0');
|
|
84
|
+
export class FsEventStore {
|
|
85
|
+
root;
|
|
86
|
+
boundaryDir;
|
|
87
|
+
boundaryCache = null;
|
|
88
|
+
opts;
|
|
89
|
+
/** True once a valid marker was seen. Only then may a read repair anything (it is a mutation). Never reset: the marker is never removed. */
|
|
90
|
+
ready = false;
|
|
91
|
+
maxAttempts;
|
|
92
|
+
seams;
|
|
93
|
+
constructor(opts) {
|
|
94
|
+
// Only the public fields are taken from `opts`; seams arrive through `createFsStoreWithSeams` alone.
|
|
95
|
+
this.opts = { target: opts.target, ...(opts.onWarning ? { onWarning: opts.onWarning } : {}), ...(opts.durability ? { durability: opts.durability } : {}) };
|
|
96
|
+
this.seams = takeFsSeams();
|
|
97
|
+
this.root = opts.target.dir;
|
|
98
|
+
this.boundaryDir = opts.target.scope === 'project' ? opts.target.projectRoot : opts.target.dir;
|
|
99
|
+
this.maxAttempts = this.seams.maxPruneAttempts ?? MAX_PRUNE_ATTEMPTS;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The trust boundary. In `trusted-directory` scope the directory is the boundary and is created on the
|
|
103
|
+
* first write if missing (same contract as the state keeper); a read of a store that does not exist yet
|
|
104
|
+
* simply finds nothing. In `project` scope the project root must already exist.
|
|
105
|
+
*/
|
|
106
|
+
boundaryFor(forWrite) {
|
|
107
|
+
if (this.boundaryCache)
|
|
108
|
+
return this.boundaryCache;
|
|
109
|
+
const t = this.opts.target;
|
|
110
|
+
if (t.scope === 'trusted-directory' && !fs.existsSync(t.dir)) {
|
|
111
|
+
if (!forWrite)
|
|
112
|
+
return null;
|
|
113
|
+
fs.mkdirSync(t.dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
}
|
|
115
|
+
this.boundaryCache = this.wrapSafe(() => (t.scope === 'project' ? projectBoundary(t.projectRoot) : trustedDirectoryBoundary(t.dir)));
|
|
116
|
+
return this.boundaryCache;
|
|
117
|
+
}
|
|
118
|
+
// ----- paths --------------------------------------------------------------
|
|
119
|
+
taskDir(taskId) {
|
|
120
|
+
if (!ID_PATTERN.test(taskId))
|
|
121
|
+
throw new StoreSecurityError(`invalid task id "${taskId}".`, 'BAD_ID');
|
|
122
|
+
return join(this.root, 'tasks', taskId);
|
|
123
|
+
}
|
|
124
|
+
eventsDir(taskId) {
|
|
125
|
+
return join(this.taskDir(taskId), 'events');
|
|
126
|
+
}
|
|
127
|
+
segmentsDir(taskId) {
|
|
128
|
+
return join(this.taskDir(taskId), 'segments');
|
|
129
|
+
}
|
|
130
|
+
refsDir(taskId) {
|
|
131
|
+
return join(this.taskDir(taskId), 'refs');
|
|
132
|
+
}
|
|
133
|
+
// ----- error mapping ------------------------------------------------------
|
|
134
|
+
wrapSafe(fn) {
|
|
135
|
+
try {
|
|
136
|
+
return fn();
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
throw this.mapWriteError(err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
mapWriteError(err) {
|
|
143
|
+
if (err instanceof SafeWriteError) {
|
|
144
|
+
if (SECURITY_WRITE_CODES.has(err.code))
|
|
145
|
+
return new StoreSecurityError(err.message, err.code);
|
|
146
|
+
return new StoreIoError(err.message, err);
|
|
147
|
+
}
|
|
148
|
+
return err instanceof Error ? err : new StoreIoError(String(err));
|
|
149
|
+
}
|
|
150
|
+
// ----- version marker, bootstrap and diagnostic mode (Design A sections 13, 46.5) -----------------------------
|
|
151
|
+
markerRead() {
|
|
152
|
+
// The marker's own staging twin (a crash between link() and unlink(tmp) of its publication) may be repaired by whoever
|
|
153
|
+
// reads it: the marker is there, so the store is initialized, and removing a provable twin changes no state.
|
|
154
|
+
const r = this.readFileObs(join(this.root, PROTOCOL_MARKER_FILE), PROTOCOL_MARKER_FILE, 1024, true);
|
|
155
|
+
if (r.k === 'bad' && SECURITY_REASONS.has(r.reason)) {
|
|
156
|
+
throw new StoreSecurityError(`${PROTOCOL_MARKER_FILE} or a directory above it is not what a managed file must be (${r.reason}); refusing to use the store.`, r.reason);
|
|
157
|
+
}
|
|
158
|
+
if (r.k !== 'ok')
|
|
159
|
+
return r;
|
|
160
|
+
try {
|
|
161
|
+
const o = JSON.parse(r.v);
|
|
162
|
+
if (typeof o === 'object' && o !== null && !Array.isArray(o) && Object.keys(o).join() === 'v') {
|
|
163
|
+
const v = o.v;
|
|
164
|
+
if (typeof v === 'number' && Number.isInteger(v) && v >= 0)
|
|
165
|
+
return { k: 'ok', v };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* falls through */
|
|
170
|
+
}
|
|
171
|
+
return { k: 'bad', reason: 'schema' };
|
|
172
|
+
}
|
|
173
|
+
hasTasks() {
|
|
174
|
+
return (this.listDir(join(this.root, 'tasks')) ?? []).some((e) => e.isDirectory() || e.isSymbolicLink());
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* What this store may be used for. Pure observation: it creates nothing and repairs nothing. A store with no marker is
|
|
178
|
+
* `diagnostic` (empty or not): reads work, every mutation is refused until the operator calls `initializeProtocol()`.
|
|
179
|
+
*/
|
|
180
|
+
async protocolStatus() {
|
|
181
|
+
const m = this.markerRead();
|
|
182
|
+
if (m.k === 'ioerr')
|
|
183
|
+
throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
|
|
184
|
+
if (m.k === 'bad')
|
|
185
|
+
return { mode: 'incompatible', reason: 'invalid-marker' };
|
|
186
|
+
if (m.k === 'ok') {
|
|
187
|
+
if (m.v !== 2)
|
|
188
|
+
return { mode: 'incompatible', reason: 'unsupported-version' };
|
|
189
|
+
this.ready = true;
|
|
190
|
+
return { mode: 'ready', version: 2 };
|
|
191
|
+
}
|
|
192
|
+
return { mode: 'diagnostic', reason: 'uninitialized', tasksPresent: this.hasTasks() };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Explicit operator action (Design A section 46.5, ADR-018 D3): publish `durable-protocol.json` (`{"v":2}`, create-only)
|
|
196
|
+
* in an EMPTY store, with every runtime of an older version stopped. It refuses a store that holds tasks (that needs the
|
|
197
|
+
* migration decision of the operator, not a bootstrap), refuses a marker of another version, and is a no-op when a valid
|
|
198
|
+
* marker is already there. Opening a store never does this.
|
|
199
|
+
*/
|
|
200
|
+
async initializeProtocol() {
|
|
201
|
+
return this.initializeProtocolSync();
|
|
202
|
+
}
|
|
203
|
+
/** The same operator action as `initializeProtocol`, for scripts and tests that are not async. */
|
|
204
|
+
initializeProtocolSync() {
|
|
205
|
+
const m = this.markerRead();
|
|
206
|
+
if (m.k === 'ioerr')
|
|
207
|
+
throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
|
|
208
|
+
if (m.k === 'bad' || (m.k === 'ok' && m.v !== 2)) {
|
|
209
|
+
throw new StoreProtocolError(`the store carries a ${PROTOCOL_MARKER_FILE} this version does not understand; it will not be touched.`, 'PROTOCOL_UNSUPPORTED');
|
|
210
|
+
}
|
|
211
|
+
if (m.k === 'ok') {
|
|
212
|
+
this.ready = true;
|
|
213
|
+
return 'already-initialized';
|
|
214
|
+
}
|
|
215
|
+
if (this.hasTasks()) {
|
|
216
|
+
throw new StoreProtocolError('the store already holds tasks; a store with tasks and no marker is only for diagnostics until the operator migrates it.', 'PROTOCOL_STORE_NOT_EMPTY');
|
|
217
|
+
}
|
|
218
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.root, { create: true, ops: this.seams.ops }));
|
|
219
|
+
try {
|
|
220
|
+
const out = createFile(parent, PROTOCOL_MARKER_FILE, '{"v":2}\n', { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: 1024, ops: this.seams.ops });
|
|
221
|
+
for (const w of out.warnings)
|
|
222
|
+
this.opts.onWarning?.(w);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
|
|
226
|
+
const again = this.markerRead();
|
|
227
|
+
if (again.k === 'ok' && again.v === 2) {
|
|
228
|
+
this.ready = true;
|
|
229
|
+
return 'already-initialized';
|
|
230
|
+
}
|
|
231
|
+
throw new StoreProtocolError(`another ${PROTOCOL_MARKER_FILE} appeared that this version does not understand; it will not be touched.`, 'PROTOCOL_UNSUPPORTED');
|
|
232
|
+
}
|
|
233
|
+
throw this.mapWriteError(err);
|
|
234
|
+
}
|
|
235
|
+
this.fsyncChain(this.root);
|
|
236
|
+
this.ready = true;
|
|
237
|
+
return 'initialized';
|
|
238
|
+
}
|
|
239
|
+
/** Called first by every public method. A write needs a valid marker; a read needs the marker to be absent or valid. */
|
|
240
|
+
gate(kind) {
|
|
241
|
+
if (this.ready)
|
|
242
|
+
return;
|
|
243
|
+
const m = this.markerRead();
|
|
244
|
+
if (m.k === 'ok' && m.v === 2) {
|
|
245
|
+
this.ready = true;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (m.k === 'ioerr')
|
|
249
|
+
throw new StoreIoError(`cannot read ${PROTOCOL_MARKER_FILE}: ${m.reason}`);
|
|
250
|
+
if (m.k === 'bad' || m.k === 'ok') {
|
|
251
|
+
throw new StoreProtocolError(`the store carries a ${PROTOCOL_MARKER_FILE} this version does not understand; it is not opened as compatible.`, 'PROTOCOL_UNSUPPORTED');
|
|
252
|
+
}
|
|
253
|
+
if (kind === 'write') {
|
|
254
|
+
throw new StoreProtocolError(`the durable store has no ${PROTOCOL_MARKER_FILE}: it is open for diagnostics only. An operator must initialize it explicitly (initializeProtocol) on an empty store; a store with tasks needs the migration procedure.`, 'PROTOCOL_NOT_INITIALIZED');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ----- directory helpers --------------------------------------------------
|
|
258
|
+
/** Verified directory (no symlink below the boundary) or `null` if it does not exist. */
|
|
259
|
+
existingDir(dir) {
|
|
260
|
+
const boundary = this.boundaryFor(false);
|
|
261
|
+
if (!boundary)
|
|
262
|
+
return false;
|
|
263
|
+
try {
|
|
264
|
+
openParent(boundary, dir, { ops: this.seams.ops });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
if (err instanceof SafeWriteError && err.code === 'WRITE_PARENT_MISSING')
|
|
269
|
+
return false;
|
|
270
|
+
throw this.mapWriteError(err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
listDir(dir) {
|
|
274
|
+
if (!this.existingDir(dir))
|
|
275
|
+
return null;
|
|
276
|
+
try {
|
|
277
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (errno(err) === 'ENOENT')
|
|
281
|
+
return null;
|
|
282
|
+
throw new StoreIoError(`cannot list "${dir}": ${err.message}`, err);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// ----- strict read with residue repair ------------------------------------
|
|
286
|
+
/**
|
|
287
|
+
* The second name of a freshly published file, if (and only if) it is
|
|
288
|
+
* provably this file's staging name: `<name>.tmp.<pid>.<rand>` in the same
|
|
289
|
+
* directory with the same dev/ino. Returns the removed name or `null`.
|
|
290
|
+
* Removing it is a mutation: a store without a valid marker (diagnostics only) never does it.
|
|
291
|
+
*/
|
|
292
|
+
repairResidue(abs, force = false) {
|
|
293
|
+
if (!this.ready && !force)
|
|
294
|
+
return null;
|
|
295
|
+
const dir = dirname(abs);
|
|
296
|
+
const name = abs.slice(dir.length + 1);
|
|
297
|
+
let final;
|
|
298
|
+
try {
|
|
299
|
+
final = fs.lstatSync(abs, { bigint: true });
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
if (!final.isFile() || final.nlink !== 2n)
|
|
305
|
+
return null;
|
|
306
|
+
let entries;
|
|
307
|
+
try {
|
|
308
|
+
entries = fs.readdirSync(dir);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const twins = [];
|
|
314
|
+
for (const e of entries) {
|
|
315
|
+
const m = TMP_NAME.exec(e);
|
|
316
|
+
if (!m || m[1] !== name)
|
|
317
|
+
continue;
|
|
318
|
+
try {
|
|
319
|
+
const st = fs.lstatSync(join(dir, e), { bigint: true });
|
|
320
|
+
if (st.isFile() && st.dev === final.dev && st.ino === final.ino)
|
|
321
|
+
twins.push(e);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
/* vanished: the writer finished itself */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (twins.length !== 1)
|
|
328
|
+
return null;
|
|
329
|
+
try {
|
|
330
|
+
fs.unlinkSync(join(dir, twins[0]));
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
if (errno(err) !== 'ENOENT')
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return twins[0];
|
|
337
|
+
}
|
|
338
|
+
/** `null` when the file is missing. Throws on anything unsafe or unreadable. */
|
|
339
|
+
readManaged(abs, label, maxBytes, taskId, diag) {
|
|
340
|
+
let hardlinkTries = 0;
|
|
341
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
342
|
+
const r = readBoundedFile(abs, label, { boundary: this.boundaryDir, maxBytes }, true);
|
|
343
|
+
if (r.ok)
|
|
344
|
+
return r.content;
|
|
345
|
+
if (r.code === 'FILE_MISSING')
|
|
346
|
+
return null;
|
|
347
|
+
if (r.code === 'FILE_CHANGED')
|
|
348
|
+
continue;
|
|
349
|
+
if (r.code === 'FILE_HARDLINKED') {
|
|
350
|
+
const repaired = this.repairResidue(abs);
|
|
351
|
+
if (repaired) {
|
|
352
|
+
diag?.push({ code: 'STAGING_RESIDUE_REPAIRED', taskId, name: repaired });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// A live writer may be between link() and unlink(): give it a moment before judging.
|
|
356
|
+
if (hardlinkTries++ < 3) {
|
|
357
|
+
sleepMs(2);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
throw new StoreSecurityError(`${label} has a second hard link that is not its own staging name; refusing to read it.`, r.code);
|
|
361
|
+
}
|
|
362
|
+
if (SECURITY_READ_CODES.has(r.code))
|
|
363
|
+
throw new StoreSecurityError(r.detail, r.code);
|
|
364
|
+
if (r.code === 'FILE_EMPTY')
|
|
365
|
+
throw new LogCorruptError(taskId, null, `${label} is empty`);
|
|
366
|
+
if (r.code === 'FILE_TOO_LARGE')
|
|
367
|
+
throw new LogCorruptError(taskId, null, `${label} exceeds ${maxBytes} bytes`);
|
|
368
|
+
throw new StoreIoError(r.detail);
|
|
369
|
+
}
|
|
370
|
+
throw new StoreIoError(`${label} kept changing while being read.`);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The same safe read, reported as an observation instead of an exception, for the places that must tell
|
|
374
|
+
* "not there" (`absent`) from "there and wrong" (`bad`: evidence) from "could not find out" (`ioerr`: never evidence).
|
|
375
|
+
*/
|
|
376
|
+
readFileObs(abs, label, maxBytes, repair = false) {
|
|
377
|
+
const fault = this.seams.readFault?.(abs);
|
|
378
|
+
if (fault)
|
|
379
|
+
return { k: 'ioerr', reason: `${fault}:${label}` };
|
|
380
|
+
if (!fs.existsSync(this.boundaryDir))
|
|
381
|
+
return { k: 'absent' };
|
|
382
|
+
let hardlinkTries = 0;
|
|
383
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
384
|
+
const r = readBoundedFile(abs, label, { boundary: this.boundaryDir, maxBytes }, true);
|
|
385
|
+
if (r.ok)
|
|
386
|
+
return { k: 'ok', v: r.content };
|
|
387
|
+
switch (r.code) {
|
|
388
|
+
case 'FILE_MISSING':
|
|
389
|
+
return { k: 'absent' };
|
|
390
|
+
case 'FILE_CHANGED':
|
|
391
|
+
continue;
|
|
392
|
+
case 'FILE_HARDLINKED': {
|
|
393
|
+
if (this.repairResidue(abs, repair))
|
|
394
|
+
continue;
|
|
395
|
+
if (hardlinkTries++ < 3) {
|
|
396
|
+
sleepMs(2);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
// Without a marker nothing may be repaired: the crash residue of a publisher is not evidence of damage.
|
|
400
|
+
return this.ready || repair ? { k: 'bad', reason: r.code } : { k: 'ioerr', reason: `${r.code}:${label}` };
|
|
401
|
+
}
|
|
402
|
+
case 'FILE_SYMLINK':
|
|
403
|
+
case 'FILE_OUTSIDE_PROJECT':
|
|
404
|
+
case 'FILE_NOT_REGULAR':
|
|
405
|
+
case 'READ_BOUNDARY_INVALID':
|
|
406
|
+
case 'FILE_EMPTY':
|
|
407
|
+
case 'FILE_TOO_LARGE':
|
|
408
|
+
return { k: 'bad', reason: r.code };
|
|
409
|
+
default:
|
|
410
|
+
return { k: 'ioerr', reason: `${r.code}:${label}` };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { k: 'ioerr', reason: `FILE_CHANGED:${label}` };
|
|
414
|
+
}
|
|
415
|
+
// ----- the prune decision register ----------------------------------------
|
|
416
|
+
registerIO(taskId) {
|
|
417
|
+
const dir = this.taskDir(taskId);
|
|
418
|
+
const read = (name, max, parse) => {
|
|
419
|
+
const r = this.readFileObs(join(dir, name), name, max);
|
|
420
|
+
return r.k === 'ok' ? parse(r.v) : r;
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
prepare: (n) => read(prepareFile(n), MAX_REGISTER_BYTES, (t) => parsePrepare(t, n, taskId)),
|
|
424
|
+
decision: (n) => read(decisionFile(n), MAX_REGISTER_BYTES, (t) => parseDecision(t, n, taskId)),
|
|
425
|
+
tombstone: () => read(TOMBSTONE_NAME, MAX_TOMBSTONE_BYTES, (t) => parseTombstoneFile(t, taskId)),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Files of the task still on disk (events, segments, references, staging). `> 0` after a commit means "deleting". */
|
|
429
|
+
residueCount(taskId) {
|
|
430
|
+
let n = 0;
|
|
431
|
+
try {
|
|
432
|
+
for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId), this.refsDir(taskId)]) {
|
|
433
|
+
for (const e of this.listDir(dir) ?? []) {
|
|
434
|
+
if (e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || REF_NAME.test(e.name) || TMP_NAME.test(e.name)))
|
|
435
|
+
n++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return 1; // unknown: assume there is something left; no decision depends on "gone"
|
|
441
|
+
}
|
|
442
|
+
return n;
|
|
443
|
+
}
|
|
444
|
+
/** `derivePruneState` over the real directory. Reads only. */
|
|
445
|
+
derive(taskId) {
|
|
446
|
+
const dir = this.taskDir(taskId);
|
|
447
|
+
if (!this.existingDir(dir))
|
|
448
|
+
return { kind: 'missing' };
|
|
449
|
+
let names;
|
|
450
|
+
try {
|
|
451
|
+
names = fs.readdirSync(dir);
|
|
452
|
+
if (this.seams.registerListing)
|
|
453
|
+
names = this.seams.registerListing(taskId, names);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (errno(err) === 'ENOENT')
|
|
457
|
+
return { kind: 'missing' };
|
|
458
|
+
return { kind: 'unreadable', reason: `io:list:${errno(err) ?? 'error'}` };
|
|
459
|
+
}
|
|
460
|
+
const st = derivePruneState(names, this.registerIO(taskId), this.maxAttempts);
|
|
461
|
+
if (st.kind === 'committed')
|
|
462
|
+
st.residue = this.residueCount(taskId);
|
|
463
|
+
return st;
|
|
464
|
+
}
|
|
465
|
+
/** The error a state that grants nothing turns into. */
|
|
466
|
+
refusal(taskId, st) {
|
|
467
|
+
switch (st.kind) {
|
|
468
|
+
case 'corrupt':
|
|
469
|
+
return new PruneDecisionCorruptError(taskId, (st.reason ?? 'register'), st.file ?? null, st.attempt ?? null);
|
|
470
|
+
case 'legacy':
|
|
471
|
+
return new PruneDecisionCorruptError(taskId, 'legacy-tombstone-unmigrated');
|
|
472
|
+
case 'missing':
|
|
473
|
+
return new PruneDecisionCorruptError(taskId, 'task-dir-missing');
|
|
474
|
+
case 'unreadable':
|
|
475
|
+
return new StoreIoError(`the prune state of task "${taskId}" cannot be read now (${st.reason ?? 'unknown'}); this refusal removes and undoes nothing. Try again.`);
|
|
476
|
+
case 'committed':
|
|
477
|
+
return new TaskPrunedError(taskId);
|
|
478
|
+
default:
|
|
479
|
+
return new StoreIoError(`unexpected prune state "${st.kind}" for task "${taskId}".`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async pruneState(taskId) {
|
|
483
|
+
this.gate('read');
|
|
484
|
+
return this.derive(taskId);
|
|
485
|
+
}
|
|
486
|
+
publishRecord(taskId, name, data) {
|
|
487
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.taskDir(taskId), { ops: this.seams.ops }));
|
|
488
|
+
try {
|
|
489
|
+
const out = createFile(parent, name, data, { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_REGISTER_BYTES, ops: this.seams.ops });
|
|
490
|
+
for (const w of out.warnings)
|
|
491
|
+
this.opts.onWarning?.(w);
|
|
492
|
+
return 'created';
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS')
|
|
496
|
+
return 'exists';
|
|
497
|
+
throw this.mapWriteError(err);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* The precondition of an irreversible prune (F-1): the log, read now, is that of a finished task whose head is the one
|
|
502
|
+
* the tombstone names. Meaningful only for an attempt that is already visible in the register (or, as a refusal that
|
|
503
|
+
* leaves no residue, just before one is published): from the moment the prepare is visible, an append that lands after
|
|
504
|
+
* this read settles through G2, finds the open attempt and closes it with `abort`, so a commit can never follow it.
|
|
505
|
+
*/
|
|
506
|
+
assertPrunable(tombstone) {
|
|
507
|
+
if (this.seams.skipPrunePrecondition)
|
|
508
|
+
return;
|
|
509
|
+
const taskId = tombstone.taskId;
|
|
510
|
+
const log = this.readLogImpl(taskId, { rawEvents: true });
|
|
511
|
+
if (!log.exists)
|
|
512
|
+
throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
|
|
513
|
+
const problem = tombstoneProblem(taskId, log.events, tombstone);
|
|
514
|
+
if (problem !== null)
|
|
515
|
+
throw new RetentionRefusedError(taskId, problem);
|
|
516
|
+
}
|
|
517
|
+
async preparePrune(tombstone) {
|
|
518
|
+
this.gate('write');
|
|
519
|
+
const taskId = tombstone.taskId;
|
|
520
|
+
for (let i = 0; i < 8; i++) {
|
|
521
|
+
const st = this.derive(taskId);
|
|
522
|
+
switch (st.kind) {
|
|
523
|
+
case 'missing':
|
|
524
|
+
throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
|
|
525
|
+
case 'preparing':
|
|
526
|
+
return { attempt: st.attempt, created: false };
|
|
527
|
+
case 'live': {
|
|
528
|
+
const n = (st.attempt ?? 0) + 1;
|
|
529
|
+
if (n > this.maxAttempts)
|
|
530
|
+
throw new PruneAttemptLimitError(taskId, this.maxAttempts);
|
|
531
|
+
this.assertPrunable(tombstone); // early refusal: nothing is written
|
|
532
|
+
if (this.publishRecord(taskId, prepareFile(n), encodePrepare(n, tombstone)) === 'created') {
|
|
533
|
+
try {
|
|
534
|
+
this.assertPrunable(tombstone); // authoritative: the prepare is visible now
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
// Whatever the reason (finished-ness, head, a log that does not verify, a read that failed), an attempt that
|
|
538
|
+
// cannot be justified is closed: abort is always safe, and it is the pruner's own attempt.
|
|
539
|
+
await this.decidePrune(taskId, n, 'abort', undefined, 'pruner').catch(() => undefined);
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
return { attempt: n, created: true };
|
|
543
|
+
}
|
|
544
|
+
continue; // somebody opened attempt n first: look again and accept theirs
|
|
545
|
+
}
|
|
546
|
+
default:
|
|
547
|
+
throw this.refusal(taskId, st);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
throw new StoreIoError(`prune attempts of task "${taskId}" kept changing; try again.`);
|
|
551
|
+
}
|
|
552
|
+
async decidePrune(taskId, attempt, outcome, tombstone, by) {
|
|
553
|
+
this.gate('write');
|
|
554
|
+
if (!Number.isInteger(attempt) || attempt < 1)
|
|
555
|
+
throw new LogCorruptError(taskId, null, `invalid prune attempt ${attempt}`);
|
|
556
|
+
if (outcome !== 'commit' && outcome !== 'abort')
|
|
557
|
+
throw new LogCorruptError(taskId, null, 'a prune decision is either "commit" or "abort"');
|
|
558
|
+
const io = this.registerIO(taskId);
|
|
559
|
+
// Only for an attempt whose valid prepare is visible to the caller.
|
|
560
|
+
const p = io.prepare(attempt);
|
|
561
|
+
if (p.k === 'absent')
|
|
562
|
+
throw new LogCorruptError(taskId, null, `there is no prepare for prune attempt ${attempt}; a decision needs one`);
|
|
563
|
+
if (p.k === 'bad')
|
|
564
|
+
throw new PruneDecisionCorruptError(taskId, 'register', prepareFile(attempt), attempt);
|
|
565
|
+
if (p.k === 'ioerr')
|
|
566
|
+
throw new StoreIoError(`cannot read ${prepareFile(attempt)} of task "${taskId}": ${p.reason}`);
|
|
567
|
+
if (outcome === 'commit' && (!tombstone || !sameTombstone(tombstone, p.v.tombstone))) {
|
|
568
|
+
throw new LogCorruptError(taskId, null, 'a commit must carry the tombstone of its attempt');
|
|
569
|
+
}
|
|
570
|
+
const existing = () => {
|
|
571
|
+
const d = io.decision(attempt);
|
|
572
|
+
if (d.k === 'ok')
|
|
573
|
+
return d.v.outcome;
|
|
574
|
+
if (d.k === 'bad')
|
|
575
|
+
throw new PruneDecisionCorruptError(taskId, 'register', decisionFile(attempt), attempt);
|
|
576
|
+
throw new StoreIoError(`cannot read ${decisionFile(attempt)} of task "${taskId}": ${d.k === 'ioerr' ? d.reason : 'vanished'}`);
|
|
577
|
+
};
|
|
578
|
+
// A later attempt can only exist if this one was decided; if its decision is missing the register has a hole,
|
|
579
|
+
// and a hole is never filled in (it is what a deleted record looks like).
|
|
580
|
+
if (io.prepare(attempt + 1).k !== 'absent') {
|
|
581
|
+
const d = io.decision(attempt);
|
|
582
|
+
if (d.k === 'absent')
|
|
583
|
+
throw new PruneDecisionCorruptError(taskId, 'gap', decisionFile(attempt), attempt);
|
|
584
|
+
return existing();
|
|
585
|
+
}
|
|
586
|
+
// A commit is irreversible: the head it names must be the head of the log, checked while the prepare is visible.
|
|
587
|
+
// A decision that already exists is the value of the register and is returned as it is, not re-judged.
|
|
588
|
+
if (outcome === 'commit' && io.decision(attempt).k === 'absent')
|
|
589
|
+
this.assertPrunable(p.v.tombstone);
|
|
590
|
+
const data = encodeDecision(attempt, outcome, p.v.tombstone, by);
|
|
591
|
+
return this.publishRecord(taskId, decisionFile(attempt), data) === 'created' ? outcome : existing();
|
|
592
|
+
}
|
|
593
|
+
// ----- adapters of the pre-register operations ----------------------------------------------------------------
|
|
594
|
+
async markPruned(tombstone) {
|
|
595
|
+
this.gate('write');
|
|
596
|
+
const taskId = tombstone.taskId;
|
|
597
|
+
const st = this.derive(taskId);
|
|
598
|
+
if (st.kind === 'preparing' || st.kind === 'committed') {
|
|
599
|
+
if (st.tombstone.headHash !== tombstone.headHash)
|
|
600
|
+
throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
await this.preparePrune(tombstone);
|
|
604
|
+
}
|
|
605
|
+
async withdrawTombstone(tombstone) {
|
|
606
|
+
this.gate('write');
|
|
607
|
+
const taskId = tombstone.taskId;
|
|
608
|
+
const st = this.derive(taskId);
|
|
609
|
+
if (st.kind === 'live' || st.kind === 'missing')
|
|
610
|
+
return; // already withdrawn
|
|
611
|
+
if (st.kind === 'committed')
|
|
612
|
+
throw new TombstoneCommittedError(taskId, 'the prune is committed; the tombstone cannot be withdrawn');
|
|
613
|
+
if (st.kind !== 'preparing')
|
|
614
|
+
throw this.refusal(taskId, st);
|
|
615
|
+
if (st.tombstone.headHash !== tombstone.headHash)
|
|
616
|
+
throw new LogCorruptError(taskId, null, 'a different tombstone exists; refusing to withdraw it');
|
|
617
|
+
const w = await this.decidePrune(taskId, st.attempt, 'abort', undefined, 'creator');
|
|
618
|
+
if (w === 'commit')
|
|
619
|
+
throw new TombstoneCommittedError(taskId, 'the prune is committed; the tombstone cannot be withdrawn');
|
|
620
|
+
}
|
|
621
|
+
async prune(tombstone, opts = {}) {
|
|
622
|
+
this.gate('write');
|
|
623
|
+
const taskId = tombstone.taskId;
|
|
624
|
+
let committed = null;
|
|
625
|
+
for (let i = 0; i < 8 && committed === null; i++) {
|
|
626
|
+
const st = this.derive(taskId);
|
|
627
|
+
switch (st.kind) {
|
|
628
|
+
case 'missing':
|
|
629
|
+
throw new LogCorruptError(taskId, null, 'cannot prune an unknown task');
|
|
630
|
+
case 'committed':
|
|
631
|
+
if (st.tombstone.headHash !== tombstone.headHash)
|
|
632
|
+
throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
|
|
633
|
+
committed = st.tombstone;
|
|
634
|
+
break;
|
|
635
|
+
case 'live':
|
|
636
|
+
case 'preparing': {
|
|
637
|
+
if (opts.requireCommit)
|
|
638
|
+
throw new RetentionRefusedError(taskId, 'no prune decision has been committed for it');
|
|
639
|
+
// The low-level form starts a prune by itself only when none was ever attempted. Once an attempt exists it can
|
|
640
|
+
// only be finished: an attempt that was ABORTED (by a task that needs the parent) is not silently replaced by a
|
|
641
|
+
// new one that commits without looking for referrers. A pruner that wants another try calls `markPruned` again
|
|
642
|
+
// and does its own check.
|
|
643
|
+
if (st.kind === 'live' && (st.attempt ?? 0) > 0)
|
|
644
|
+
throw new RetentionRefusedError(taskId, 'its last prune attempt was aborted by a task that needs it');
|
|
645
|
+
if (st.kind === 'preparing' && st.tombstone.headHash !== tombstone.headHash) {
|
|
646
|
+
throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
|
|
647
|
+
}
|
|
648
|
+
// The low-level form: publish the attempt and its commit without looking for referrers. The register
|
|
649
|
+
// decides, so a concurrent abort wins over this call or this call wins over it, never both.
|
|
650
|
+
// `st` was read before `preparePrune` re-derives: an abort that lands in between would make it open a NEW attempt
|
|
651
|
+
// here. Only the attempt this call saw (or the first one) may be committed; anything else is a replaced attempt.
|
|
652
|
+
const expected = st.kind === 'preparing' ? st.attempt : 1;
|
|
653
|
+
const opened = await this.preparePrune(tombstone);
|
|
654
|
+
const attempt = opened.attempt;
|
|
655
|
+
if (attempt !== expected) {
|
|
656
|
+
if (opened.created)
|
|
657
|
+
await this.decidePrune(taskId, attempt, 'abort', undefined, 'pruner');
|
|
658
|
+
throw new RetentionRefusedError(taskId, 'its prune attempt was aborted by a task that needs it');
|
|
659
|
+
}
|
|
660
|
+
const prepared = this.registerIO(taskId).prepare(attempt);
|
|
661
|
+
const tomb = prepared.k === 'ok' ? prepared.v.tombstone : tombstone;
|
|
662
|
+
if (tomb.headHash !== tombstone.headHash)
|
|
663
|
+
throw new LogCorruptError(taskId, null, 'a different tombstone already exists');
|
|
664
|
+
const w = await this.decidePrune(taskId, attempt, 'commit', tomb, 'pruner');
|
|
665
|
+
if (w === 'abort')
|
|
666
|
+
throw new RetentionRefusedError(taskId, 'the prune attempt was aborted by a task that needs it');
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
default:
|
|
670
|
+
throw this.refusal(taskId, st);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (committed === null)
|
|
674
|
+
throw new StoreIoError(`the prune state of task "${taskId}" kept changing; try again.`);
|
|
675
|
+
this.auditCommitted(taskId, committed);
|
|
676
|
+
return this.rollForward(taskId, committed);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The events (and covered segment lines) still on disk with `seq <= upTo`, each verified on its own; no gap is an error,
|
|
680
|
+
* because a roll-forward that was interrupted leaves gaps. A segment wins over a loose copy of the same slot (the reader's
|
|
681
|
+
* rule). Anything that cannot be read safely or does not verify throws: the audit then refuses instead of guessing.
|
|
682
|
+
*/
|
|
683
|
+
survivingEvents(taskId, upTo) {
|
|
684
|
+
const bySeq = new Map();
|
|
685
|
+
// ORDER MATTERS (F-1), the same as in `readLogImpl`: loose files first, segments second. Compaction publishes a segment
|
|
686
|
+
// BEFORE it removes the loose files it covers and nothing but the roll-forward of a prune ever removes a segment (and that
|
|
687
|
+
// publishes the tombstone first, which `auditCommitted` reads afterwards). So a slot that is missing from the loose
|
|
688
|
+
// files, or whose file vanishes before it is read, was removed after its segment was published, and the segment is in
|
|
689
|
+
// the listing taken afterwards. The other order cannot see a segment that is published between the two listings.
|
|
690
|
+
const eventEntries = this.listDir(this.eventsDir(taskId)) ?? [];
|
|
691
|
+
this.seams.auditHook?.('listed');
|
|
692
|
+
for (const e of eventEntries) {
|
|
693
|
+
const m = EVENT_NAME.exec(e.name);
|
|
694
|
+
if (!m || !e.isFile() || Number(m[1]) > upTo)
|
|
695
|
+
continue; // only regular event files are ever read, and nothing past the head is evidence
|
|
696
|
+
const seq = Number(m[1]);
|
|
697
|
+
const content = this.readManaged(join(this.eventsDir(taskId), e.name), `event ${e.name}`, MAX_EVENT_BYTES + 4096, taskId);
|
|
698
|
+
if (content === null)
|
|
699
|
+
continue; // removed meanwhile: a compaction published its segment first, and it is listed below
|
|
700
|
+
const ev = parseEvent(content.trim(), taskId, seq);
|
|
701
|
+
if (ev.seq !== seq)
|
|
702
|
+
throw new LogCorruptError(taskId, seq, `file ${e.name} holds event ${ev.seq}`);
|
|
703
|
+
bySeq.set(seq, ev);
|
|
704
|
+
}
|
|
705
|
+
const segs = [];
|
|
706
|
+
for (const e of this.listDir(this.segmentsDir(taskId)) ?? []) {
|
|
707
|
+
const m = SEGMENT_NAME.exec(e.name);
|
|
708
|
+
if (m && e.isFile() && Number(m[1]) <= upTo)
|
|
709
|
+
segs.push({ name: e.name, from: Number(m[1]), to: Number(m[2]) });
|
|
710
|
+
}
|
|
711
|
+
for (const g of segs) {
|
|
712
|
+
const content = this.readManaged(join(this.segmentsDir(taskId), g.name), `segment ${g.name}`, MAX_SEGMENT_BYTES, taskId);
|
|
713
|
+
if (content === null)
|
|
714
|
+
continue;
|
|
715
|
+
const lines = content.split('\n').filter((l) => l.length > 0);
|
|
716
|
+
if (lines.length !== g.to - g.from + 1)
|
|
717
|
+
throw new LogCorruptError(taskId, g.from, `segment ${g.name} holds ${lines.length} events, its name says ${g.to - g.from + 1}`);
|
|
718
|
+
lines.forEach((line, i) => {
|
|
719
|
+
const ev = parseEvent(line, taskId, g.from + i);
|
|
720
|
+
if (ev.seq !== g.from + i)
|
|
721
|
+
throw new LogCorruptError(taskId, ev.seq, `segment ${g.name} is not contiguous`);
|
|
722
|
+
if (ev.seq <= upTo)
|
|
723
|
+
bySeq.set(ev.seq, ev);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* O-1: a committed register is not self-proving (its checksum is unkeyed), so the data still on disk must not contradict
|
|
730
|
+
* it before anything is unlinked. See `committedProblem` for what a partly removed log can and cannot prove. Reads only.
|
|
731
|
+
*
|
|
732
|
+
* I-1: the data is read FIRST and `tombstone.json` AFTER it. The roll-forward publishes the tombstone before its first
|
|
733
|
+
* unlink and nothing ever removes it, so a tombstone that is still absent after the data was read proves that no unlink
|
|
734
|
+
* had begun when the data was read (it was absent at every earlier moment too): a gap in the data is then not a removal
|
|
735
|
+
* but a torn observation or real damage. The loose files are read before the segments are listed (see `survivingEvents`), so a
|
|
736
|
+
* compaction that moves events into a segment meanwhile does not tear the observation. Read the
|
|
737
|
+
* other way round, a concurrent pruner that publishes the tombstone and removes events between the two reads is taken
|
|
738
|
+
* for damage. A gap seen without a tombstone is still looked at again a few times before it is refused (a defence in depth,
|
|
739
|
+
* not what F-1 relies on). No lock: a pruner that races this audit only ever turns the verdict
|
|
740
|
+
* from "the whole log must be there" into "a part may be gone", and the roll-forward still checks the tombstone it finds.
|
|
741
|
+
*/
|
|
742
|
+
auditCommitted(taskId, tombstone) {
|
|
743
|
+
let problem = null;
|
|
744
|
+
for (let pass = 0; pass < AUDIT_PASSES; pass++) {
|
|
745
|
+
let events = null;
|
|
746
|
+
try {
|
|
747
|
+
events = this.survivingEvents(taskId, tombstone.headSeq);
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
if (!(err instanceof LogCorruptError))
|
|
751
|
+
throw err;
|
|
752
|
+
problem = err.message; // an event that does not verify on its own does not heal
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
this.seams.auditHook?.('surveyed');
|
|
756
|
+
const published = this.registerIO(taskId).tombstone();
|
|
757
|
+
if (published.k === 'ioerr')
|
|
758
|
+
throw new StoreIoError(`cannot read tombstone.json of task "${taskId}": ${published.reason}; this call removed no task data. Try again.`);
|
|
759
|
+
const deletionStarted = published.k !== 'absent'; // the roll-forward publishes it before its first unlink
|
|
760
|
+
try {
|
|
761
|
+
problem = committedProblem(taskId, events, tombstone, deletionStarted);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
if (!(err instanceof LogCorruptError))
|
|
765
|
+
throw err;
|
|
766
|
+
problem = err.message;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
if (problem === null || deletionStarted)
|
|
770
|
+
break; // with a tombstone the verdict is already the lenient one
|
|
771
|
+
}
|
|
772
|
+
if (problem !== null)
|
|
773
|
+
throw new PruneDecisionCorruptError(taskId, 'commit-audit', null, null, false, problem);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Prune cleanup (class C, section 30.3): only for a verified, consistent `COMMITTED`. Publishes `tombstone.json` from the
|
|
777
|
+
* commit, then removes the data of the task. It never touches the register, `tombstone.json` or the task directory,
|
|
778
|
+
* decides nothing, and finishing a half-done run is the same call again.
|
|
779
|
+
*/
|
|
780
|
+
rollForward(taskId, tombstone) {
|
|
781
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.taskDir(taskId), { ops: this.seams.ops }));
|
|
782
|
+
try {
|
|
783
|
+
const out = createFile(parent, TOMBSTONE_NAME, encodeTombstone(tombstone), { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_TOMBSTONE_BYTES, ops: this.seams.ops });
|
|
784
|
+
for (const w of out.warnings)
|
|
785
|
+
this.opts.onWarning?.(w);
|
|
786
|
+
}
|
|
787
|
+
catch (err) {
|
|
788
|
+
if (!(err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS'))
|
|
789
|
+
throw this.mapWriteError(err);
|
|
790
|
+
const t = parseTombstoneFile(this.readManaged(join(this.taskDir(taskId), TOMBSTONE_NAME), 'tombstone', MAX_TOMBSTONE_BYTES, taskId) ?? '', taskId);
|
|
791
|
+
if (t.k !== 'ok' || !sameTombstone(t.v, tombstone))
|
|
792
|
+
throw new PruneDecisionCorruptError(taskId, 'commit-audit', TOMBSTONE_NAME);
|
|
793
|
+
}
|
|
794
|
+
let removed = 0;
|
|
795
|
+
for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId), this.refsDir(taskId)]) {
|
|
796
|
+
const entries = this.listDir(dir);
|
|
797
|
+
if (!entries)
|
|
798
|
+
continue;
|
|
799
|
+
let leftovers = 0;
|
|
800
|
+
for (const e of entries) {
|
|
801
|
+
if (!(e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || REF_NAME.test(e.name) || TMP_NAME.test(e.name)))) {
|
|
802
|
+
leftovers++;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
(this.seams.removeFile ?? fs.unlinkSync)(join(dir, e.name));
|
|
807
|
+
removed++;
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
if (errno(err) !== 'ENOENT')
|
|
811
|
+
throw new StoreIoError(`cannot remove ${e.name}: ${err.message}`, err);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (leftovers === 0) {
|
|
815
|
+
try {
|
|
816
|
+
fs.rmdirSync(dir);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
if (errno(err) !== 'ENOENT' && errno(err) !== 'ENOTEMPTY')
|
|
820
|
+
throw new StoreIoError(`cannot remove ${dir}: ${err.message}`, err);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return { removedFiles: removed };
|
|
825
|
+
}
|
|
826
|
+
// ----- G1 / G2 ---------------------------------------------------------------------------------------------------
|
|
827
|
+
/**
|
|
828
|
+
* G1 (section 24.6): an optional early refusal, so that a write to a pruned or damaged task does not leave a file
|
|
829
|
+
* behind. Safety never depends on it (G2 decides); turning it off changes only how much is left over.
|
|
830
|
+
*/
|
|
831
|
+
earlyCheck(taskId) {
|
|
832
|
+
const st = this.derive(taskId);
|
|
833
|
+
if (this.seams.earlyPruneCheck === false)
|
|
834
|
+
return st;
|
|
835
|
+
if (st.kind === 'committed' || st.kind === 'corrupt' || st.kind === 'legacy' || st.kind === 'unreadable')
|
|
836
|
+
throw this.refusal(taskId, st);
|
|
837
|
+
return st;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* G2 (section 24.2): after a file was published, ask the register. It never unlinks anything: a published file is
|
|
841
|
+
* shared state (F-1, class A is forbidden). `PREPARING` is closed with an `abort` (always safe), at most
|
|
842
|
+
* `MAX_WRITE_RESOLVE` times; a pruner that keeps opening attempts leaves the outcome UNKNOWN, never a deletion.
|
|
843
|
+
*/
|
|
844
|
+
async settle(taskId, by) {
|
|
845
|
+
let last = 'attempts kept being opened';
|
|
846
|
+
for (let i = 0; i < MAX_WRITE_RESOLVE; i++) {
|
|
847
|
+
const st = this.derive(taskId);
|
|
848
|
+
switch (st.kind) {
|
|
849
|
+
case 'live':
|
|
850
|
+
return { t: 'CONFIRMED' };
|
|
851
|
+
case 'committed':
|
|
852
|
+
return { t: 'PRUNED' };
|
|
853
|
+
case 'corrupt':
|
|
854
|
+
case 'legacy':
|
|
855
|
+
return { t: 'CORRUPT', state: st };
|
|
856
|
+
case 'preparing':
|
|
857
|
+
try {
|
|
858
|
+
await this.decidePrune(taskId, st.attempt, 'abort', undefined, by);
|
|
859
|
+
}
|
|
860
|
+
catch (err) {
|
|
861
|
+
if (err instanceof PruneDecisionCorruptError)
|
|
862
|
+
return { t: 'CORRUPT', state: { kind: 'corrupt', reason: err.reason, ...(err.file ? { file: err.file } : {}) } };
|
|
863
|
+
if (err instanceof StoreIoError)
|
|
864
|
+
return { t: 'UNKNOWN', why: err.message };
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
last = 'a new prune attempt was opened while the previous one was being closed';
|
|
868
|
+
continue;
|
|
869
|
+
default:
|
|
870
|
+
return { t: 'UNKNOWN', why: st.kind === 'missing' ? 'the task directory disappeared' : `unreadable: ${st.reason ?? ''}` };
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return { t: 'UNKNOWN', why: last };
|
|
874
|
+
}
|
|
875
|
+
// ----- EventStore ---------------------------------------------------------
|
|
876
|
+
async listTaskIds() {
|
|
877
|
+
this.gate('read');
|
|
878
|
+
const entries = this.listDir(join(this.root, 'tasks'));
|
|
879
|
+
if (!entries)
|
|
880
|
+
return [];
|
|
881
|
+
return entries
|
|
882
|
+
.filter((e) => e.isDirectory() && ID_PATTERN.test(e.name))
|
|
883
|
+
.map((e) => e.name)
|
|
884
|
+
.sort();
|
|
885
|
+
}
|
|
886
|
+
async readLog(taskId, opts = {}) {
|
|
887
|
+
this.gate('read');
|
|
888
|
+
return this.readLogImpl(taskId, opts);
|
|
889
|
+
}
|
|
890
|
+
readLogImpl(taskId, opts) {
|
|
891
|
+
const after = opts.afterSeq ?? 0;
|
|
892
|
+
const taskDir = this.taskDir(taskId);
|
|
893
|
+
// Segments are immutable: once parsed in this call they are not parsed again on a retry.
|
|
894
|
+
const parsed = new Map();
|
|
895
|
+
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt++) {
|
|
896
|
+
const diagnostics = [];
|
|
897
|
+
if (!this.existingDir(taskDir))
|
|
898
|
+
return { exists: false, tombstone: null, events: [], compactedThrough: 0, diagnostics };
|
|
899
|
+
const st = this.derive(taskId);
|
|
900
|
+
if (st.kind === 'unreadable')
|
|
901
|
+
throw this.refusal(taskId, st);
|
|
902
|
+
const hidden = st.kind === 'committed' || st.kind === 'legacy' || (st.kind === 'preparing' && !opts.rawEvents);
|
|
903
|
+
if (hidden) {
|
|
904
|
+
// Pruned (or a prune attempt is open). An open attempt is shown the way the pre-register protocol showed a published
|
|
905
|
+
// tombstone — "tombstone visible, data intact" — because the consumers of this store (claim, createTask) rely on
|
|
906
|
+
// that to refuse work against a task that may be about to disappear. `prune.kind` is the authority.
|
|
907
|
+
for (const dir of [this.eventsDir(taskId), this.segmentsDir(taskId)]) {
|
|
908
|
+
for (const e of this.listDir(dir) ?? []) {
|
|
909
|
+
if (e.isFile() && (EVENT_NAME.test(e.name) || SEGMENT_NAME.test(e.name) || TMP_NAME.test(e.name))) {
|
|
910
|
+
diagnostics.push({ code: 'PRUNE_INCOMPLETE', taskId, name: e.name });
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
for (const a of st.anomalies ?? [])
|
|
915
|
+
diagnostics.push({ code: 'UNKNOWN_PRUNE_ARTIFACT', taskId, name: a.replace(/^UNKNOWN_PRUNE:/, '') });
|
|
916
|
+
return { exists: true, tombstone: st.tombstone, prune: st, events: [], compactedThrough: 0, diagnostics };
|
|
917
|
+
}
|
|
918
|
+
for (const a of st.anomalies ?? [])
|
|
919
|
+
diagnostics.push({ code: 'UNKNOWN_PRUNE_ARTIFACT', taskId, name: a.replace(/^UNKNOWN_PRUNE:/, '') });
|
|
920
|
+
// ORDER MATTERS. Compaction publishes a segment BEFORE it removes the loose files it covers. So if the
|
|
921
|
+
// loose files are listed first and the segments second, an event missing from the first listing was
|
|
922
|
+
// removed before it, hence its segment was already published and appears in the second. Listing in the
|
|
923
|
+
// other order lets a reader that is slow between the two listings see a hole on every attempt.
|
|
924
|
+
const eventEntries = this.listDir(this.eventsDir(taskId)) ?? [];
|
|
925
|
+
const segEntries = this.listDir(this.segmentsDir(taskId)) ?? [];
|
|
926
|
+
this.seams.afterListing?.(attempt);
|
|
927
|
+
const segments = [];
|
|
928
|
+
for (const e of segEntries) {
|
|
929
|
+
const m = SEGMENT_NAME.exec(e.name);
|
|
930
|
+
if (m && e.isFile())
|
|
931
|
+
segments.push({ name: e.name, from: Number(m[1]), to: Number(m[2]) });
|
|
932
|
+
else if (TMP_NAME.test(e.name))
|
|
933
|
+
diagnostics.push({ code: 'STAGING_FILE_PRESENT', taskId, name: e.name });
|
|
934
|
+
else
|
|
935
|
+
diagnostics.push({ code: 'UNKNOWN_ENTRY', taskId, name: e.name });
|
|
936
|
+
}
|
|
937
|
+
segments.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
938
|
+
const segEnd = segments.reduce((m, s) => Math.max(m, s.to), 0);
|
|
939
|
+
// Read the loose files first, right after listing them: they are small and they are what a
|
|
940
|
+
// concurrent compaction removes. The (slow) segment parsing comes after, when a vanished file
|
|
941
|
+
// is already known to be covered.
|
|
942
|
+
const bySeq = new Map();
|
|
943
|
+
let vanished = false;
|
|
944
|
+
for (const e of eventEntries) {
|
|
945
|
+
const m = EVENT_NAME.exec(e.name);
|
|
946
|
+
if (!m) {
|
|
947
|
+
diagnostics.push({ code: TMP_NAME.test(e.name) ? 'STAGING_FILE_PRESENT' : 'UNKNOWN_ENTRY', taskId, name: e.name });
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!e.isFile())
|
|
951
|
+
throw new StoreSecurityError(`event entry ${e.name} of task "${taskId}" is not a regular file.`, 'FILE_NOT_REGULAR');
|
|
952
|
+
const seq = Number(m[1]);
|
|
953
|
+
if (seq <= after)
|
|
954
|
+
continue;
|
|
955
|
+
// Covered by a segment of THIS listing (Cov(s) != empty, section 35.1), not "below the highest segment": a slot
|
|
956
|
+
// outside every segment is canonical even when a later segment exists (F-G-1).
|
|
957
|
+
if (segments.some((g) => g.from <= seq && seq <= g.to)) {
|
|
958
|
+
diagnostics.push({ code: 'LOOSE_COPY_OF_COMPACTED_EVENT', taskId, name: e.name });
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const content = this.readManaged(join(this.eventsDir(taskId), e.name), `event ${e.name}`, MAX_EVENT_BYTES + 4096, taskId, diagnostics);
|
|
962
|
+
if (content === null) {
|
|
963
|
+
vanished = true;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const ev = parseEvent(content.trim(), taskId, seq);
|
|
967
|
+
if (ev.seq !== seq)
|
|
968
|
+
throw new LogCorruptError(taskId, seq, `file ${e.name} holds event ${ev.seq}`);
|
|
969
|
+
bySeq.set(seq, ev);
|
|
970
|
+
}
|
|
971
|
+
for (const s of segments) {
|
|
972
|
+
if (s.to <= after)
|
|
973
|
+
continue;
|
|
974
|
+
let evs = parsed.get(s.name);
|
|
975
|
+
if (!evs) {
|
|
976
|
+
const content = this.readManaged(join(this.segmentsDir(taskId), s.name), `segment ${s.name}`, MAX_SEGMENT_BYTES, taskId);
|
|
977
|
+
if (content === null)
|
|
978
|
+
continue;
|
|
979
|
+
const lines = content.split('\n').filter((l) => l.length > 0);
|
|
980
|
+
if (lines.length !== s.to - s.from + 1)
|
|
981
|
+
throw new LogCorruptError(taskId, s.from, `segment ${s.name} holds ${lines.length} events, its name says ${s.to - s.from + 1}`);
|
|
982
|
+
evs = lines.map((line, i) => {
|
|
983
|
+
const ev = parseEvent(line, taskId, s.from + i);
|
|
984
|
+
if (ev.seq !== s.from + i)
|
|
985
|
+
throw new LogCorruptError(taskId, ev.seq, `segment ${s.name} is not contiguous`);
|
|
986
|
+
return ev;
|
|
987
|
+
});
|
|
988
|
+
parsed.set(s.name, evs);
|
|
989
|
+
}
|
|
990
|
+
for (const ev of evs) {
|
|
991
|
+
const dup = bySeq.get(ev.seq);
|
|
992
|
+
if (dup && dup.hash !== ev.hash)
|
|
993
|
+
throw new LogCorruptError(taskId, ev.seq, 'overlapping segments disagree');
|
|
994
|
+
if (ev.seq > after)
|
|
995
|
+
bySeq.set(ev.seq, ev); // a segment may straddle `after`; hand back only the suffix asked for
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const events = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
999
|
+
let contiguous = events.length === 0 || events[0].seq === after + 1;
|
|
1000
|
+
for (let i = 1; contiguous && i < events.length; i++)
|
|
1001
|
+
contiguous = events[i].seq === events[i - 1].seq + 1;
|
|
1002
|
+
if (!contiguous || vanished) {
|
|
1003
|
+
// Either a writer is mid-way (a later file listed before an earlier one became visible) or a
|
|
1004
|
+
// compaction moved files into a segment after we listed. A fresh pass resolves both.
|
|
1005
|
+
if (attempt < READ_ATTEMPTS - 1) {
|
|
1006
|
+
sleepMs(2 * (attempt + 1));
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
const firstBad = events.find((ev, i) => (i === 0 ? ev.seq !== after + 1 : ev.seq !== events[i - 1].seq + 1));
|
|
1010
|
+
throw new LogCorruptError(taskId, firstBad?.seq ?? null, vanished ? 'event files kept vanishing while being read' : 'events are missing from the log');
|
|
1011
|
+
}
|
|
1012
|
+
return { exists: true, tombstone: null, ...(st.kind === 'live' ? {} : { prune: st }), events, compactedThrough: segEnd, diagnostics };
|
|
1013
|
+
}
|
|
1014
|
+
throw new StoreIoError(`could not obtain a stable view of task "${taskId}".`);
|
|
1015
|
+
}
|
|
1016
|
+
fileExists(abs) {
|
|
1017
|
+
try {
|
|
1018
|
+
fs.lstatSync(abs);
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
catch (err) {
|
|
1022
|
+
if (errno(err) === 'ENOENT')
|
|
1023
|
+
return false;
|
|
1024
|
+
throw new StoreIoError(`cannot inspect "${abs}": ${err.message}`, err);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
// ----- slots, segments, canonical content (Design A sections 35 and 40) ---------------------------------------
|
|
1028
|
+
slotName(seq) {
|
|
1029
|
+
return `${pad(seq)}.json`;
|
|
1030
|
+
}
|
|
1031
|
+
/** The loose file of a slot as an observation (trimmed line), never an exception. */
|
|
1032
|
+
readSlot(taskId, seq) {
|
|
1033
|
+
const r = this.readFileObs(join(this.eventsDir(taskId), this.slotName(seq)), `event ${this.slotName(seq)}`, MAX_EVENT_BYTES + 4096);
|
|
1034
|
+
return r.k === 'ok' ? { k: 'ok', v: r.v.trim() } : r;
|
|
1035
|
+
}
|
|
1036
|
+
listSegments(taskId) {
|
|
1037
|
+
const out = [];
|
|
1038
|
+
for (const e of this.listDir(this.segmentsDir(taskId)) ?? []) {
|
|
1039
|
+
const m = SEGMENT_NAME.exec(e.name);
|
|
1040
|
+
if (m && e.isFile())
|
|
1041
|
+
out.push({ name: e.name, from: Number(m[1]), to: Number(m[2]) });
|
|
1042
|
+
}
|
|
1043
|
+
return out.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
1044
|
+
}
|
|
1045
|
+
/** A segment as its lines, validated the way `readLog` validates it. */
|
|
1046
|
+
readSegmentLines(taskId, seg) {
|
|
1047
|
+
const r = this.readFileObs(join(this.segmentsDir(taskId), seg.name), `segment ${seg.name}`, MAX_SEGMENT_BYTES);
|
|
1048
|
+
if (r.k !== 'ok')
|
|
1049
|
+
return r;
|
|
1050
|
+
const lines = r.v.split('\n').filter((l) => l.length > 0);
|
|
1051
|
+
if (lines.length !== seg.to - seg.from + 1)
|
|
1052
|
+
return { k: 'bad', reason: 'line-count' };
|
|
1053
|
+
try {
|
|
1054
|
+
lines.forEach((line, i) => {
|
|
1055
|
+
if (parseEvent(line, taskId, seg.from + i).seq !== seg.from + i)
|
|
1056
|
+
throw new Error('not contiguous');
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
return { k: 'bad', reason: 'content' };
|
|
1061
|
+
}
|
|
1062
|
+
return { k: 'ok', v: lines };
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* `CanonRead` for the slots `from..to` (section 40.2). Every loose file is read BEFORE the single listing of
|
|
1066
|
+
* `segments/` (the order is what makes a slot that was compacted in between visible, section 35.3), and a slot
|
|
1067
|
+
* covered by a segment takes the segment's row: a loose file next to it is a copy or an orphan and is not consulted
|
|
1068
|
+
* (CEI-2), so an I/O error on it does not matter. Writes nothing.
|
|
1069
|
+
*/
|
|
1070
|
+
canonRead(taskId, from, to) {
|
|
1071
|
+
const loose = new Map();
|
|
1072
|
+
for (let s = from; s <= to; s++)
|
|
1073
|
+
loose.set(s, this.readSlot(taskId, s));
|
|
1074
|
+
const segs = this.listSegments(taskId);
|
|
1075
|
+
const cache = new Map();
|
|
1076
|
+
const out = new Map();
|
|
1077
|
+
for (let s = from; s <= to; s++) {
|
|
1078
|
+
const cov = segs.filter((g) => g.from <= s && s <= g.to);
|
|
1079
|
+
if (cov.length > 0) {
|
|
1080
|
+
let row = null;
|
|
1081
|
+
let verdict = null;
|
|
1082
|
+
for (const g of cov) {
|
|
1083
|
+
let lines = cache.get(g.name);
|
|
1084
|
+
if (!lines) {
|
|
1085
|
+
lines = this.readSegmentLines(taskId, g);
|
|
1086
|
+
cache.set(g.name, lines);
|
|
1087
|
+
}
|
|
1088
|
+
if (lines.k === 'ioerr')
|
|
1089
|
+
verdict = { t: 'IOERR' };
|
|
1090
|
+
else if (lines.k === 'bad')
|
|
1091
|
+
verdict = { t: 'BAD' };
|
|
1092
|
+
else if (lines.k === 'absent')
|
|
1093
|
+
verdict = { t: 'GONE' };
|
|
1094
|
+
else {
|
|
1095
|
+
const mine = lines.v[s - g.from];
|
|
1096
|
+
if (row !== null && row !== mine)
|
|
1097
|
+
verdict = { t: 'BAD' }; // overlapping segments disagree
|
|
1098
|
+
row = mine;
|
|
1099
|
+
}
|
|
1100
|
+
if (verdict)
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
1103
|
+
out.set(s, verdict ?? { t: 'SEGMENT', line: row });
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const c = loose.get(s);
|
|
1107
|
+
out.set(s, c.k === 'ok' ? { t: 'LOOSE', line: c.v } : c.k === 'absent' ? { t: 'EMPTY' } : c.k === 'ioerr' ? { t: 'IOERR' } : { t: 'BAD' });
|
|
1108
|
+
}
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Canonical Event Identity (section 35.2), run right after the `link` of a loose file (or its `EEXIST`): the slot was
|
|
1113
|
+
* read first (`slot`), the listing of `segments/` comes strictly after. Confirmation needs the slot to equal the event
|
|
1114
|
+
* of the caller, canonically: the row of a covering segment, or — if nothing covers it — the loose file.
|
|
1115
|
+
*/
|
|
1116
|
+
cei(taskId, seq, line, mode, slot) {
|
|
1117
|
+
let cov;
|
|
1118
|
+
try {
|
|
1119
|
+
cov = this.listSegments(taskId).filter((g) => g.from <= seq && seq <= g.to);
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return { t: 'UNKNOWN' };
|
|
1123
|
+
}
|
|
1124
|
+
if (cov.length === 0)
|
|
1125
|
+
return slot === line ? { t: 'EVIDENCE', via: 'loose' } : { t: 'CONFLICT' };
|
|
1126
|
+
let row = null;
|
|
1127
|
+
for (const g of cov) {
|
|
1128
|
+
const lines = this.readSegmentLines(taskId, g);
|
|
1129
|
+
if (lines.k === 'ioerr')
|
|
1130
|
+
return { t: 'UNKNOWN' };
|
|
1131
|
+
if (lines.k === 'bad')
|
|
1132
|
+
throw new LogCorruptError(taskId, seq, `segment ${g.name} is not readable as a log segment; refusing to judge event ${seq}.`);
|
|
1133
|
+
if (lines.k === 'absent')
|
|
1134
|
+
return { t: 'LOST' };
|
|
1135
|
+
const mine = lines.v[seq - g.from];
|
|
1136
|
+
if (row !== null && row !== mine)
|
|
1137
|
+
throw new LogCorruptError(taskId, seq, 'overlapping segments disagree');
|
|
1138
|
+
row = mine;
|
|
1139
|
+
}
|
|
1140
|
+
// The caller's own copy in a covered slot is a redundant file (class B): readers ignore it. Only the publisher of a
|
|
1141
|
+
// file may remove it, and only if it is byte-identical to what the publisher wrote.
|
|
1142
|
+
if (mode === 'own')
|
|
1143
|
+
this.removeOwnLooseFile(taskId, this.slotName(seq), line);
|
|
1144
|
+
return row === line ? { t: 'EVIDENCE', via: 'segment' } : { t: 'CONFLICT' };
|
|
1145
|
+
}
|
|
1146
|
+
/** AV-4: the error for a write that is not valid, chosen from the register only AFTER the refusal (section 31.1). */
|
|
1147
|
+
refuseWrite(taskId, otherwise) {
|
|
1148
|
+
const st = this.derive(taskId);
|
|
1149
|
+
if (st.kind === 'committed' || st.kind === 'corrupt' || st.kind === 'legacy')
|
|
1150
|
+
return this.refusal(taskId, st);
|
|
1151
|
+
return otherwise;
|
|
1152
|
+
}
|
|
1153
|
+
async append(event) {
|
|
1154
|
+
this.gate('write');
|
|
1155
|
+
const taskId = event.taskId;
|
|
1156
|
+
const seq = event.seq;
|
|
1157
|
+
const taskDir = this.taskDir(taskId);
|
|
1158
|
+
const line = serializeEvent(event);
|
|
1159
|
+
const name = this.slotName(seq);
|
|
1160
|
+
// AV (section 31.1): validity of the append. Mandatory, independent of G1, reads only the log.
|
|
1161
|
+
if (seq > 1) {
|
|
1162
|
+
if (!this.existingDir(taskDir))
|
|
1163
|
+
throw new PruneDecisionCorruptError(taskId, 'task-dir-missing');
|
|
1164
|
+
let hasPredecessor = this.fileExists(join(this.eventsDir(taskId), this.slotName(seq - 1)));
|
|
1165
|
+
if (!hasPredecessor)
|
|
1166
|
+
hasPredecessor = this.listSegments(taskId).some((g) => g.from <= seq - 1 && seq - 1 <= g.to);
|
|
1167
|
+
if (!hasPredecessor)
|
|
1168
|
+
throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'predecessor event is missing; refusing to publish past a hole'));
|
|
1169
|
+
}
|
|
1170
|
+
this.earlyCheck(taskId);
|
|
1171
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.eventsDir(taskId), { create: true, ops: this.seams.ops }));
|
|
1172
|
+
await this.seams.beforePublish?.(event);
|
|
1173
|
+
let mode = 'own';
|
|
1174
|
+
let slot = line;
|
|
1175
|
+
for (let attempt = 0;; attempt++) {
|
|
1176
|
+
try {
|
|
1177
|
+
const out = createFile(parent, name, `${line}\n`, {
|
|
1178
|
+
mode: 0o600,
|
|
1179
|
+
durability: this.opts.durability ?? 'full',
|
|
1180
|
+
maxBytes: MAX_EVENT_BYTES + 4096,
|
|
1181
|
+
ops: this.seams.ops,
|
|
1182
|
+
});
|
|
1183
|
+
for (const w of out.warnings)
|
|
1184
|
+
this.opts.onWarning?.(w);
|
|
1185
|
+
mode = 'own';
|
|
1186
|
+
slot = line;
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
catch (err) {
|
|
1190
|
+
if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
|
|
1191
|
+
// Not a verdict: the slot is occupied, by whom and with what is decided by CEI, never by "the bytes are equal".
|
|
1192
|
+
const r = this.readSlot(taskId, seq);
|
|
1193
|
+
if (r.k === 'ok') {
|
|
1194
|
+
mode = 'adopt';
|
|
1195
|
+
slot = r.v;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
if (r.k === 'ioerr')
|
|
1199
|
+
throw new AppendOutcomeUnknownError(taskId, seq, new Error(r.reason));
|
|
1200
|
+
if (r.k === 'bad')
|
|
1201
|
+
throw new LogCorruptError(taskId, seq, `the file in slot ${seq} cannot be judged (${r.reason}); refusing to confirm anything.`);
|
|
1202
|
+
// The file was taken away between the EEXIST and the read (compaction cleanup): publish again.
|
|
1203
|
+
if (attempt + 1 >= MAX_WRITE_RESOLVE)
|
|
1204
|
+
throw new StoreIoError(`slot ${seq} of task "${taskId}" kept changing; nothing was published or confirmed.`);
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (err instanceof SafeWriteError && err.publication === 'UNKNOWN')
|
|
1208
|
+
throw new AppendOutcomeUnknownError(taskId, seq, err);
|
|
1209
|
+
// The directory we were about to write into is gone or replaced: if the task was pruned meanwhile, say so.
|
|
1210
|
+
if (err instanceof SafeWriteError && (err.code === 'WRITE_PARENT_CHANGED' || err.code === 'WRITE_PARENT_MISSING')) {
|
|
1211
|
+
if (this.derive(taskId).kind === 'committed')
|
|
1212
|
+
throw new TaskPrunedError(taskId);
|
|
1213
|
+
}
|
|
1214
|
+
throw this.mapWriteError(err);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
if (seq === 1 && mode === 'own' && (this.opts.durability ?? 'full') === 'full')
|
|
1218
|
+
this.fsyncChain(join(this.eventsDir(taskId)));
|
|
1219
|
+
const ev = this.cei(taskId, seq, line, mode, slot);
|
|
1220
|
+
if (ev.t === 'CONFLICT')
|
|
1221
|
+
return 'conflict';
|
|
1222
|
+
if (ev.t === 'UNKNOWN')
|
|
1223
|
+
throw new AppendOutcomeUnknownError(taskId, seq, new Error('the slot could not be examined'));
|
|
1224
|
+
const g2 = await this.settle(taskId, 'append');
|
|
1225
|
+
if (g2.t === 'PRUNED')
|
|
1226
|
+
throw new TaskPrunedError(taskId);
|
|
1227
|
+
if (g2.t === 'CORRUPT')
|
|
1228
|
+
throw this.published(this.refusal(taskId, g2.state));
|
|
1229
|
+
if (ev.t === 'LOST' || g2.t === 'UNKNOWN')
|
|
1230
|
+
throw new AppendOutcomeUnknownError(taskId, seq, new Error(g2.t === 'UNKNOWN' ? g2.why : 'the covering segment is gone'));
|
|
1231
|
+
// CEI-6: 'appended' only for own (this call's link created the slot) AND loose evidence AND G2 LIVE; every other
|
|
1232
|
+
// canonical match is 'adopted' and gives no right derived from winning the slot.
|
|
1233
|
+
return mode === 'own' && ev.via === 'loose' ? 'appended' : 'adopted';
|
|
1234
|
+
}
|
|
1235
|
+
/** The file is already visible: say so on the corruption error. */
|
|
1236
|
+
published(err) {
|
|
1237
|
+
if (err instanceof PruneDecisionCorruptError)
|
|
1238
|
+
return new PruneDecisionCorruptError(err.taskId, err.reason, err.file, err.attempt, true);
|
|
1239
|
+
return err;
|
|
1240
|
+
}
|
|
1241
|
+
/** Removes `name` only if its bytes are exactly `line` (a copy the caller published itself, class B). */
|
|
1242
|
+
removeOwnLooseFile(taskId, name, line) {
|
|
1243
|
+
const abs = join(this.eventsDir(taskId), name);
|
|
1244
|
+
try {
|
|
1245
|
+
const content = this.readManaged(abs, `event ${name}`, MAX_EVENT_BYTES + 4096, taskId);
|
|
1246
|
+
if (content !== null && content.trim() === line)
|
|
1247
|
+
fs.unlinkSync(abs);
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
/* a leftover loose copy of a compacted event is ignored by readers (diagnostic) */
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
async compact(taskId, throughSeq, events) {
|
|
1254
|
+
this.gate('write');
|
|
1255
|
+
const taskDir = this.taskDir(taskId);
|
|
1256
|
+
// K0 (section 40.3)
|
|
1257
|
+
if (this.fileExists(join(taskDir, TOMBSTONE_NAME)))
|
|
1258
|
+
throw new TaskPrunedError(taskId);
|
|
1259
|
+
const early = this.earlyCheck(taskId);
|
|
1260
|
+
if (events.length === 0)
|
|
1261
|
+
return { segment: null, packedEvents: 0, removedFiles: 0 };
|
|
1262
|
+
const first = events[0];
|
|
1263
|
+
const last = events[events.length - 1];
|
|
1264
|
+
// Position i must hold slot first.seq + i: matching ends and length do not exclude a duplicate or a swapped pair, and
|
|
1265
|
+
// the segment written from such an input would not be contiguous (AC-CC-1: each line is the Canon of ITS slot).
|
|
1266
|
+
if (last.seq !== throughSeq || last.seq - first.seq + 1 !== events.length || events.some((e, i) => e.seq !== first.seq + i)) {
|
|
1267
|
+
throw new LogCorruptError(taskId, first.seq, 'compaction input is not a contiguous range ending at throughSeq');
|
|
1268
|
+
}
|
|
1269
|
+
const lines = events.map((e) => serializeEvent(e));
|
|
1270
|
+
const data = `${lines.join('\n')}\n`;
|
|
1271
|
+
if (Buffer.byteLength(data, 'utf8') > MAX_SEGMENT_BYTES)
|
|
1272
|
+
throw new StoreIoError('segment would exceed the size ceiling; pack fewer events.');
|
|
1273
|
+
// K1: the task directory must be there; compaction never creates a task.
|
|
1274
|
+
if (early.kind === 'missing' || !this.existingDir(taskDir))
|
|
1275
|
+
throw new PruneDecisionCorruptError(taskId, 'task-dir-missing');
|
|
1276
|
+
// K2 (CC-1, CC-2, CC-4): before anything is created or published, every slot of the input must equal the canonical
|
|
1277
|
+
// content of that slot. The store enforces it; the caller is not trusted. A refusal leaves no trace.
|
|
1278
|
+
const canon = this.canonRead(taskId, first.seq, last.seq);
|
|
1279
|
+
for (let i = 0; i < events.length; i++) {
|
|
1280
|
+
const seq = events[i].seq;
|
|
1281
|
+
const c = canon.get(seq);
|
|
1282
|
+
if (c.t === 'LOOSE' || c.t === 'SEGMENT') {
|
|
1283
|
+
if (c.line !== lines[i])
|
|
1284
|
+
throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'compaction input is not the canonical log'));
|
|
1285
|
+
}
|
|
1286
|
+
else if (c.t === 'EMPTY') {
|
|
1287
|
+
throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'compaction input slot has no canonical event'));
|
|
1288
|
+
}
|
|
1289
|
+
else if (c.t === 'IOERR') {
|
|
1290
|
+
throw new StoreIoError(`slot ${seq} of task "${taskId}" cannot be read now; nothing was published. Try again.`);
|
|
1291
|
+
}
|
|
1292
|
+
else if (c.t === 'GONE') {
|
|
1293
|
+
throw new TaskPrunedError(taskId);
|
|
1294
|
+
}
|
|
1295
|
+
else {
|
|
1296
|
+
throw this.refuseWrite(taskId, new LogCorruptError(taskId, seq, 'the canonical content of the slot cannot be judged'));
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// K3: publish
|
|
1300
|
+
const name = `S${pad(first.seq)}-${pad(last.seq)}.jsonl`;
|
|
1301
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.segmentsDir(taskId), { create: true, ops: this.seams.ops }));
|
|
1302
|
+
try {
|
|
1303
|
+
const out = createFile(parent, name, data, { mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: MAX_SEGMENT_BYTES, ops: this.seams.ops });
|
|
1304
|
+
for (const w of out.warnings)
|
|
1305
|
+
this.opts.onWarning?.(w);
|
|
1306
|
+
}
|
|
1307
|
+
catch (err) {
|
|
1308
|
+
if (!(err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS'))
|
|
1309
|
+
throw this.mapWriteError(err);
|
|
1310
|
+
// A concurrent/interrupted compaction made the same segment: fine iff it is byte-identical (checked below).
|
|
1311
|
+
}
|
|
1312
|
+
this.fsyncChain(this.segmentsDir(taskId));
|
|
1313
|
+
// K4: read the segment back; this proves the bytes, not the canonical identity (that was K2).
|
|
1314
|
+
const back = this.readManaged(join(this.segmentsDir(taskId), name), `segment ${name}`, MAX_SEGMENT_BYTES, taskId);
|
|
1315
|
+
if (back === null || sha256Hex(back) !== sha256Hex(data)) {
|
|
1316
|
+
throw new LogCorruptError(taskId, first.seq, `segment ${name} does not read back as written; loose events are kept`);
|
|
1317
|
+
}
|
|
1318
|
+
// K5: the segment is published and verified: only now are the loose copies of THESE events redundant (class B,
|
|
1319
|
+
// CL-1..CL-4). A loose file is removed only if it is byte-identical to the event just packed.
|
|
1320
|
+
const lineBySeq = new Map(events.map((e, i) => [e.seq, lines[i]]));
|
|
1321
|
+
let removed = 0;
|
|
1322
|
+
for (const e of this.listDir(this.eventsDir(taskId)) ?? []) {
|
|
1323
|
+
const m = EVENT_NAME.exec(e.name);
|
|
1324
|
+
if (!m || !e.isFile())
|
|
1325
|
+
continue;
|
|
1326
|
+
const want = lineBySeq.get(Number(m[1]));
|
|
1327
|
+
if (want === undefined)
|
|
1328
|
+
continue;
|
|
1329
|
+
if (this.removeLooseIfIdentical(taskId, e.name, want))
|
|
1330
|
+
removed++;
|
|
1331
|
+
}
|
|
1332
|
+
this.fsyncDir(this.eventsDir(taskId));
|
|
1333
|
+
// K6: G2. It never takes the segment back.
|
|
1334
|
+
const g2 = await this.settle(taskId, 'compact');
|
|
1335
|
+
if (g2.t === 'PRUNED')
|
|
1336
|
+
throw new TaskPrunedError(taskId);
|
|
1337
|
+
if (g2.t === 'CORRUPT')
|
|
1338
|
+
throw this.published(this.refusal(taskId, g2.state));
|
|
1339
|
+
if (g2.t === 'UNKNOWN')
|
|
1340
|
+
throw new StoreIoError(`segment ${name} of task "${taskId}" was published, but the prune state could not be settled (${g2.why}); the segment stays. Try again.`);
|
|
1341
|
+
return { segment: name, packedEvents: events.length, removedFiles: removed };
|
|
1342
|
+
}
|
|
1343
|
+
/** Removes the loose event file `name` iff its content is exactly `line`. `true` if it was removed. */
|
|
1344
|
+
removeLooseIfIdentical(taskId, name, line) {
|
|
1345
|
+
const abs = join(this.eventsDir(taskId), name);
|
|
1346
|
+
const content = this.readManaged(abs, `event ${name}`, MAX_EVENT_BYTES + 4096, taskId);
|
|
1347
|
+
if (content === null || content.trim() !== line)
|
|
1348
|
+
return false;
|
|
1349
|
+
try {
|
|
1350
|
+
fs.unlinkSync(abs);
|
|
1351
|
+
return true;
|
|
1352
|
+
}
|
|
1353
|
+
catch (err) {
|
|
1354
|
+
if (errno(err) === 'ENOENT')
|
|
1355
|
+
return false;
|
|
1356
|
+
throw new StoreIoError(`cannot remove ${name}: ${err.message}`, err);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async sweepCompacted(taskId) {
|
|
1360
|
+
this.gate('write');
|
|
1361
|
+
const st = this.derive(taskId);
|
|
1362
|
+
if (st.kind === 'committed')
|
|
1363
|
+
throw new TaskPrunedError(taskId);
|
|
1364
|
+
if (st.kind === 'corrupt' || st.kind === 'legacy' || st.kind === 'unreadable')
|
|
1365
|
+
throw this.refusal(taskId, st);
|
|
1366
|
+
const log = this.readLogImpl(taskId, { rawEvents: true }); // verified view; events up to `compactedThrough` come from the segments
|
|
1367
|
+
if (log.compactedThrough === 0)
|
|
1368
|
+
return { removedFiles: 0, keptFiles: 0 };
|
|
1369
|
+
// Only a loose file the reader itself found covered by a segment is redundant (F-G-1: a slot outside every segment is
|
|
1370
|
+
// canonical, and for it `log.events` holds the loose file, which would be compared with itself).
|
|
1371
|
+
const covered = new Set(log.diagnostics.filter((d) => d.code === 'LOOSE_COPY_OF_COMPACTED_EVENT').map((d) => d.name));
|
|
1372
|
+
const lineBySeq = new Map(log.events.filter((e) => e.seq <= log.compactedThrough).map((e) => [e.seq, serializeEvent(e)]));
|
|
1373
|
+
let removedFiles = 0;
|
|
1374
|
+
let keptFiles = 0;
|
|
1375
|
+
for (const e of this.listDir(this.eventsDir(taskId)) ?? []) {
|
|
1376
|
+
const m = EVENT_NAME.exec(e.name);
|
|
1377
|
+
if (!m || !e.isFile())
|
|
1378
|
+
continue;
|
|
1379
|
+
const want = lineBySeq.get(Number(m[1]));
|
|
1380
|
+
if (want === undefined || !covered.has(e.name))
|
|
1381
|
+
continue; // not covered by a segment: a live event, not ours to touch
|
|
1382
|
+
if (this.removeLooseIfIdentical(taskId, e.name, want))
|
|
1383
|
+
removedFiles++;
|
|
1384
|
+
else if (this.fileExists(join(this.eventsDir(taskId), e.name)))
|
|
1385
|
+
keptFiles++; // differs: possibly a paused writer's file or foreign data
|
|
1386
|
+
}
|
|
1387
|
+
this.fsyncDir(this.eventsDir(taskId));
|
|
1388
|
+
return { removedFiles, keptFiles };
|
|
1389
|
+
}
|
|
1390
|
+
async pinReference(parentId, childId, at) {
|
|
1391
|
+
this.gate('write');
|
|
1392
|
+
if (!ID_PATTERN.test(childId))
|
|
1393
|
+
throw new StoreSecurityError(`invalid task id "${childId}".`, 'BAD_ID');
|
|
1394
|
+
const taskDir = this.taskDir(parentId);
|
|
1395
|
+
if (!this.existingDir(taskDir))
|
|
1396
|
+
return 'parent-missing';
|
|
1397
|
+
const before = this.derive(parentId);
|
|
1398
|
+
if (before.kind === 'committed')
|
|
1399
|
+
return 'parent-pruned';
|
|
1400
|
+
if (before.kind === 'corrupt' || before.kind === 'legacy' || before.kind === 'unreadable')
|
|
1401
|
+
throw this.refusal(parentId, before);
|
|
1402
|
+
if (before.kind === 'missing')
|
|
1403
|
+
return 'parent-missing';
|
|
1404
|
+
const name = `${childId}.json`;
|
|
1405
|
+
try {
|
|
1406
|
+
const parent = this.wrapSafe(() => openParent(this.boundaryFor(true), this.refsDir(parentId), { create: true, ops: this.seams.ops }));
|
|
1407
|
+
const out = createFile(parent, name, `${JSON.stringify({ v: 1, childId, at })}\n`, {
|
|
1408
|
+
mode: 0o600, durability: this.opts.durability ?? 'full', maxBytes: 1024, ops: this.seams.ops,
|
|
1409
|
+
});
|
|
1410
|
+
for (const w of out.warnings)
|
|
1411
|
+
this.opts.onWarning?.(w);
|
|
1412
|
+
}
|
|
1413
|
+
catch (err) {
|
|
1414
|
+
if (err instanceof SafeWriteError && err.code === 'WRITE_TARGET_EXISTS') {
|
|
1415
|
+
/* pinned before (a retry of the same creation): the pin is shared, not owned */
|
|
1416
|
+
}
|
|
1417
|
+
else if (err instanceof SafeWriteError && (err.code === 'WRITE_PARENT_CHANGED' || err.code === 'WRITE_PARENT_MISSING') && this.derive(parentId).kind === 'committed') {
|
|
1418
|
+
return 'parent-pruned';
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
throw this.mapWriteError(err);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
this.fsyncChain(this.refsDir(parentId));
|
|
1425
|
+
// The pin is visible. G2 asks the register only AFTER publishing: a prune that opened its attempt before this point is
|
|
1426
|
+
// closed here with an `abort` (or has committed, and the pin is left for the roll-forward); one that opens it later
|
|
1427
|
+
// will find the pin. The pin is never taken back (section 24.1).
|
|
1428
|
+
const g2 = await this.settle(parentId, 'pin');
|
|
1429
|
+
if (g2.t === 'PRUNED')
|
|
1430
|
+
return 'parent-pruned';
|
|
1431
|
+
if (g2.t === 'CORRUPT')
|
|
1432
|
+
throw this.refusal(parentId, g2.state);
|
|
1433
|
+
if (g2.t === 'UNKNOWN')
|
|
1434
|
+
throw new StoreIoError(`the reference to task "${parentId}" was recorded, but its prune state could not be settled (${g2.why}); the pin stays. Try again.`);
|
|
1435
|
+
return 'pinned';
|
|
1436
|
+
}
|
|
1437
|
+
async listReferences(parentId) {
|
|
1438
|
+
this.gate('read');
|
|
1439
|
+
const out = [];
|
|
1440
|
+
for (const e of this.listDir(this.refsDir(parentId)) ?? []) {
|
|
1441
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9_-]{0,63})\.json$/.exec(e.name);
|
|
1442
|
+
if (!m || !e.isFile())
|
|
1443
|
+
continue;
|
|
1444
|
+
const content = this.readManaged(join(this.refsDir(parentId), e.name), `reference ${e.name}`, 1024 + 4096, parentId);
|
|
1445
|
+
if (content === null)
|
|
1446
|
+
continue;
|
|
1447
|
+
let at = new Date(0).toISOString(); // unreadable: counts as old, so only an existing active child still blocks
|
|
1448
|
+
try {
|
|
1449
|
+
const o = JSON.parse(content);
|
|
1450
|
+
if (typeof o.at === 'string' && Number.isFinite(Date.parse(o.at)))
|
|
1451
|
+
at = o.at;
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
/* keep the default */
|
|
1455
|
+
}
|
|
1456
|
+
out.push({ childId: m[1], at });
|
|
1457
|
+
}
|
|
1458
|
+
return out.sort((a, b) => (a.childId < b.childId ? -1 : 1));
|
|
1459
|
+
}
|
|
1460
|
+
/** fsync `dir` and every ancestor down to the store root: directory entries created by mkdir are not durable until their parent is synced. */
|
|
1461
|
+
fsyncChain(dir) {
|
|
1462
|
+
let cur = dir;
|
|
1463
|
+
for (let i = 0; i < 6; i++) {
|
|
1464
|
+
this.fsyncDir(cur);
|
|
1465
|
+
if (cur === this.root)
|
|
1466
|
+
return;
|
|
1467
|
+
const up = dirname(cur);
|
|
1468
|
+
if (up === cur)
|
|
1469
|
+
return;
|
|
1470
|
+
cur = up;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Mode A (the only mode claimed): a failed directory fsync is a warning of the write primitive, never a reason to refuse or
|
|
1475
|
+
* to continue differently — durability verification (`DV`) is Mode B and is not called here (Design A section 18.2).
|
|
1476
|
+
*/
|
|
1477
|
+
fsyncDir(dir) {
|
|
1478
|
+
if ((this.opts.durability ?? 'full') !== 'full')
|
|
1479
|
+
return;
|
|
1480
|
+
let fd = null;
|
|
1481
|
+
try {
|
|
1482
|
+
fd = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
1483
|
+
fs.fsyncSync(fd);
|
|
1484
|
+
}
|
|
1485
|
+
catch {
|
|
1486
|
+
/* best effort: the segment itself was fsynced by the write primitive */
|
|
1487
|
+
}
|
|
1488
|
+
finally {
|
|
1489
|
+
if (fd !== null)
|
|
1490
|
+
fs.closeSync(fd);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|