Skip to content

feat(storage): add durable file SessionRepository - #4674

Open
MicroGery wants to merge 2 commits into
codex/2370-session-repository-contractfrom
codex/2370-local-durable-adapter
Open

feat(storage): add durable file SessionRepository#4674
MicroGery wants to merge 2 commits into
codex/2370-session-repository-contractfrom
codex/2370-local-durable-adapter

Conversation

@MicroGery

@MicroGery MicroGery commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements phase 2 of #2370 on top of #4662: an internal durable local adapter for the established Session checkpoint contract. It is intentionally not yet a public package entrypoint or a production composition consumer.

The adapter keeps immutable Bundle and Manifest bytes in non-overwritable local files verified by digest. Session heads, CAS revisions, commit receipts, and Fork records live in a small atomically replaced local control-plane document.

All control-plane state mutations take a cross-process atomic-mkdir lock. The control-plane document fsyncs a private temporary file before atomic rename and syncs its containing directory. Immutable objects are independently published through a fsynced temporary file, no-replace link, and directory-chain sync to the storage root. A crash may leave a lock directory, in which case writers fail closed instead of guessing that it is safe to steal; reads continue from the last complete state document.

Semantics covered

  • Current and exact checkout survives adapter reopen.
  • Concurrent adapters serialize head CAS, so one stale writer receives revision_conflict.
  • Immutable object references are derived from digest and media type; altered or missing bytes fail closed.
  • Pending Fork claims retain the admitted source checkpoint across source-head advance and adapter reopen; target creation can then resume across the persisted before/after-target boundaries.
  • Corrupt control-plane JSON fails closed with integrity_mismatch.

This is a stacked PR: its base is the #4662 branch. It must merge after #4662 is merged or be retargeted and rebased then.

Non-goals

  • Remote/cloud metadata or object-store adapter.
  • A public package export or production composition consumer; this PR is an internal staged foundation.
  • Automatic recovery or stealing of a stale local writer lock.
  • Activation/Fork orchestration, target state re-keying, or Bundle hydration.

Refs #2370

Verification

  • npm workspace Storage typecheck
  • npm workspace Core build
  • npm workspace Storage clean and build
  • Focused compiled SessionRepository, file-session-repository, and public-entrypoint tests: 27 passed
  • Full compiled Storage suite: 1,120 passed, 8 skipped
  • Biome check for the new source and test files

AI use

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Codex implemented the local durable adapter, its tests, and this PR description under human contributor ownership.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current head 3b086e70a3b5307de0a70e5e565cf02ebebc0370 (OPEN, MERGEABLE, no hosted check-runs yet). One P1 and one P2 below; the diff is 2 files (+1215: 1004 implementation, 211 tests).

P1 — the new adapter has no package surface and no production consumer, if it claims to deliver a usable workspace adapter

packages/storage/src/file-session-repository.ts:72-80 exports openFileSessionRepository, but packages/storage/package.json:7-62 has no ./file-session-repository export. Importing it from the built package entry reproducibly yields ERR_PACKAGE_PATH_NOT_EXPORTED, and a repo-wide search finds only the new tests importing via relative path — no production caller. So the 1004-line implementation is currently unreachable to @maka/storage consumers; the feature is effectively test-only. If this is intentionally an intermediate foundation with no consumer yet, please state that staging boundary in the PR; otherwise add the export, a real caller, and integration tests before merging.

P2 — first creation of an object-prefix directory does not durably publish the full directory chain

file-session-repository.ts:290-303 creates objects/<2-char-prefix> with recursive mkdir, writes the temp file with sync(), then after linking only calls syncDirectory(dirname(destination)). But session-repository.ts:77-85 requires publish to return only once the exact bytes/metadata are durably readable. When the prefix (or objects) is newly created in this call, fsyncing just the leaf directory does not persist the parent directory entries; after a crash the whole new prefix can be lost, violating that contract. The existing stable-storage.ts:102-123 already provides syncDirectoryChain with a root boundary, and other publish paths in this repo use it after new-directory creation. Please sync to the storage root (or an equivalent full boundary) and add a crash/persistence test for first-time new-prefix creation.

What was checked on this head

@maka/core and @maka/storage builds pass; compiled file-session tests 4/4 and existing session-repository tests 18/18 pass; git diff --check clean. What I could not judge: real power-loss directory-entry durability is not proven by local tests, and there are no hosted checks on this head yet.


Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.

简体中文

本条结论全部来自 @未开智选手 的审查。我自己没有读这份 diff;我核的是当前 head 有没有漂移、以及 exact-head 的 CI 状态。当前 head 是 3b086e7,可合并未关闭,暂无线上检查。P1 是新增实现没有包导出也没有生产调用方,P2 是首次建目录链时持久化发布不同步完整链。修好或明确阶段边界后再审。

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 3, 2026
@MicroGery
MicroGery force-pushed the codex/2370-local-durable-adapter branch from 3b086e7 to 47c5c50 Compare September 4, 2026 02:55

@likun666661 likun666661 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 47c5c509d2f56430b71bee7f6cdb4e18ed81663a and the stacked interaction with #4662. Focused tests pass on the exact head and on a synthetic merge with current main; the full Storage suite passes with Node experimental warnings suppressed. Two P1 correctness/durability gaps and two P2 conformance/operability gaps are inline. No hosted checks are currently reported for this head.

sessionId,
agentId,
head,
nextRevisionNumber: requireRevisionNumber(value.nextRevisionNumber),

@likun666661 likun666661 Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Validate revision allocation against the decoded head

nextRevisionNumber is admitted independently with only a >= 2 check. A syntactically valid damaged state can therefore reopen successfully with head r2 and nextRevisionNumber: 2; the next commit then publishes a different checkpoint under r2 again. I reproduced exactly that, so one SessionRevisionRef can denote two different checkpoints instead of the adapter failing closed. Decode should validate the complete session invariant: created revision is r1, the current revision and next counter are canonical and monotonic, lineage agrees across the session records, and commit receipts cannot contradict the session. Add a regression that mutates the counter in otherwise-valid JSON and expects integrity_mismatch.

}
try {
await linkNoReplace(temporary, destination);
await syncDirectoryChain(dirname(destination), this.storageRoot);

@likun666661 likun666661 Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Establish the durability barrier on the EEXIST path too

A concurrent publisher can observe EEXIST after another process has linked destination but before that process fsyncs the directory chain. This branch suppresses EEXIST and proceeds directly to assertReadable, so the second publish may return successfully while the directory entry is still not durable; a power loss in that window can lose an object whose publication already returned. Run syncDirectoryChain(dirname(destination), storageRoot) after the link attempt regardless of whether this writer created the link or observed EEXIST, then verify readability. A multi-process fault-injection test should pin this race.

await assertCheckpointReadable(this.objectStore, prior.result.checkpoint);
return copyCommittedSessionRevision(prior.result);
}
await assertCheckpointReadable(this.objectStore, admitted.checkpoint);

@likun666661 likun666661 Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve the contract error precedence before reading candidate objects

Unlike the in-memory conformance implementation, this path verifies the candidate checkpoint before checking whether the Session exists or whether expectedRevision is stale. Reproduction: advance r1 to r2, remove the now-unreferenced candidate Manifest, then call commit with expected r1; this adapter returns object_not_found instead of the contract-required stable revision_conflict. createSession has the same ordering mismatch for an already-existing Session. Perform a state preflight before the expensive object read, then recheck under the mutation lock, and run the same parameterized conformance cases against both implementations.


async publish(input: ImmutableObjectInput): Promise<ImmutableObjectRef> {
const admitted = admitImmutableObjectInput(input);
const bytes = await readSourceBytes(admitted);

@likun666661 likun666661 Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Stream file-backed objects instead of buffering every Bundle

Both publication here and assertReadable below use readFile, so every large Session Bundle is loaded completely into memory before it is copied or verified. Checkout also hashes the whole Bundle through that path. This makes the file adapter vulnerable to memory exhaustion on exactly the large immutable objects this port is intended to hold, and there is no adapter-owned byte bound. Stream file inputs into the private temporary file while counting and hashing, and stream verification with an explicit caller-owned limit; the bytes variant can remain bounded in memory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants