fix: preserve OPFS recovery authority during cleanup

Repair the compensating half of the OPFS put saga.

The coordinator now owns a single abortPreparedPut() driven by a
composition-owned bounded signal instead of the caller's already aborted one,
and the worker client no longer issues a duplicate fire-and-forget abort.
Journal rows and budget reservations are released only after the physical
effect is confirmed CLEANED or ALREADY_CLEAN; a timeout, malformed response or
EFFECT_UNKNOWN keeps PREPARING/FILES_READY and returns OBJECT_RECONCILE.

New writes carry a transaction-unique physicalGenerationId through the staging
receipt, manifest path and prepared object, so a late compensation deletes only
its own transaction's directory even when a newer transaction legitimately
reuses the same logical generation. v1 paths, receipts and prepared objects stay
readable through the rollback window.

Abort and cleanup hold the origin mutation lease through physical deletion and
staging removal. A transaction that never reached staging returns ALREADY_CLEAN
without waiting for the lease, which would otherwise deadlock against the BEGIN
it is cancelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-13 23:21:58 +09:00
co-authored by Claude Opus 5
parent 6d1e44f206
commit 618da9abf5
11 changed files with 678 additions and 109 deletions
@@ -690,11 +690,20 @@ default physical layout은 구현과 동일하게 다음과 같다.
```text
/ca-frontend-opfs-v1/
authorities/<authorityToken>/<namespaceToken>/<partitionToken>/
objects/<object-id-prefix>/<opaque-object-id>/<generation>/manifest.json
objects/<object-id-prefix>/<opaque-object-id>/<generation>/manifest.json # physical v1 (read-only)
objects/<object-id-prefix>/<opaque-object-id>/g<generation>-<token>/manifest.json # physical v2 (new writes)
chunks/sha256/<digest-prefix>/<digest>.bin
staging/<transaction-id>/receipt.json
```
physical v2는 STO-01 수정의 일부다. logical `generation`은 설계상 transaction 간에
재사용되므로, 늦게 도착한 T1 보상이 같은 logical generation을 쓰는 T2의 디렉터리를
지울 수 있었다. v2는 transaction-unique `physicalGenerationId` fencing token을
경로, staging receipt, prepared object에 함께 기록해 보상이 자기 transaction의
디렉터리만 삭제하도록 만든다. expand 단계에서는 v1 경로/receipt/prepared object를
계속 읽고 새 write만 v2로 쓴다. rollback window가 끝나기 전에 v1 physical
generation을 일괄 삭제하지 않는다.
구조화 metadata, query, revision, refcount와 operation journal은 IndexedDB가
소유한다. OPFS에는 immutable chunk와 bounded runtime-schema-validated manifest만
둔다. readable `scope.namespace`는 경로에 쓰지 않는다.
@@ -736,6 +745,23 @@ COMMITTED <- 사용자에게 보이는 유일한 commit point
CLEANED -> journal 제거
```
보상(compensation)은 saga의 반쪽이며 다음 규칙을 따른다.
- journal row와 budget reservation은 physical cleanup effect가
`CLEANED` 또는 `ALREADY_CLEAN`으로 확인된 뒤에만 해제한다. timeout, crash,
malformed response, `EFFECT_UNKNOWN`은 성공이 아니며 `PREPARING`/`FILES_READY`를
그대로 남기고 `OBJECT_RECONCILE`로 반환한다.
- coordinator가 `abortPreparedPut()` 하나만 소유한다. worker client는 prepare 실패
시 별도의 fire-and-forget abort를 발행하지 않는다. 중복 보상은 아직 남아 있어야
할 journal row를 조기에 지우는 경로였다.
- 보상은 caller signal을 상속하지 않는다. composition이 소유한 bounded
`compensationSignal`을 사용하므로 이미 abort된 caller가 cleanup RPC 자체를
시작조차 못 하게 만들 수 없다.
- abort/cleanup은 origin mutation Web Lock을 physical 삭제와 staging 제거가 끝날
때까지 계속 보유한다. lease를 먼저 release하지 않는다. 단, staging이 아직 없는
transaction은 삭제할 것이 없으므로 lock을 기다리지 않고 `ALREADY_CLEAN`을
반환한다. 이는 자기 자신이 취소하는 BEGIN과의 deadlock을 막는다.
- `PREPARING` crash: partial staging을 검증 후 resume하거나 purge한다.
- `FILES_READY` crash: expected generation과 digest가 맞으면 idempotent logical
commit, 아니면 quarantine한다.