Extract one atomic-write / relocation-rollback engine shared by pageops and mediaops #116

Closed
opened 2026-07-18 14:20:09 +02:00 by dominik.polakovics · 1 comment

This was generated by AI during triage.

Found by an architecture review (deepening opportunities — turning shallow modules into deep ones).

Files: internal/pageops/relocate.go (~295-460), internal/mediaops/relocate.go (~22-70), internal/pageops/fmedit.go:159 + internal/mediaops/relocate.go:30 (writeFileKeepMode, identical twice), internal/server/server.go:1066 (atomicWriteFile, a third variant).

Problem: the "precompute edits in memory → single atomic os.Rename → write pages through a test-swappable seam → restore original bytes + reverse rename on failure" discipline is implemented in parallel in pageops and mediaops — two adapters of the same idea, i.e. a real seam that was never extracted. In both packages the write seam for rollback tests is a hidden package-level function variable rather than a parameter.

Solution: extract a relocation-engine module both packages drive; make the write seam an explicit parameter. Consolidate the three atomic-write helpers into it.

Benefits: Leverage — rollback correctness (the scariest code in a CMS that promises "never leave the site broken") is written once. Locality — crash-safety tests concentrate on one module instead of two mirrored suites, and the test seam stops being ambient package state.

> *This was generated by AI during triage.* Found by an architecture review (deepening opportunities — turning shallow modules into deep ones). **Files**: `internal/pageops/relocate.go` (~295-460), `internal/mediaops/relocate.go` (~22-70), `internal/pageops/fmedit.go:159` + `internal/mediaops/relocate.go:30` (`writeFileKeepMode`, identical twice), `internal/server/server.go:1066` (`atomicWriteFile`, a third variant). **Problem**: the "precompute edits in memory → single atomic `os.Rename` → write pages through a test-swappable seam → restore original bytes + reverse rename on failure" discipline is implemented in parallel in `pageops` and `mediaops` — two adapters of the same idea, i.e. a real seam that was never extracted. In both packages the write seam for rollback tests is a hidden package-level function variable rather than a parameter. **Solution**: extract a relocation-engine module both packages drive; make the write seam an explicit parameter. Consolidate the three atomic-write helpers into it. **Benefits**: *Leverage* — rollback correctness (the scariest code in a CMS that promises "never leave the site broken") is written once. *Locality* — crash-safety tests concentrate on one module instead of two mirrored suites, and the test seam stops being ambient package state.
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Extract the relocation-rollback engine that pageops and mediaops each implement in parallel into one shared internal package, with the write seam as an explicit parameter, and consolidate the duplicated file-write helpers.

Current behavior:

  • internal/pageops has an unexported relocate engine behind Rename/Move/MoveAt: every edit is precomputed in memory (nothing on disk touched), one atomic os.Rename relocates the bundle, then each edit is written through a package-level function variable (writeRelocateEdit); on a write failure the files already written are restored from their original bytes (best-effort) and the rename is reversed, with a distinct "the site needs manual attention" error when the reverse rename fails too.
  • internal/mediaops has its own unexported relocate behind RenameFile/MoveFile/RenameFolder/MoveFolder, mirroring the same discipline with its own seam variable (writeMediaEdit) plus an explicit pre-rename target-absence check (os.Lstat → conflict error).
  • writeFileKeepMode (overwrite a file preserving its permission bits) is duplicated byte-identically in pageops and mediaops.
  • internal/server has a third, mechanically different helper, atomicWriteFile (temp file in the same directory + rename over the target, preserving mode), used by the page-save path.
  • In both relocation packages the rollback-test seam is ambient package state: tests swap the package var to force a mid-phase write failure (permission bits can't fail under CI's root runner).

Desired behavior:

A new leaf-level internal package (agent picks the name; it must not import pageops or mediaopsmediaops already imports pageops, so the engine has to sit below both) exporting:

  • an edit type carrying the target absolute path, the new bytes, and the original bytes for restoration;
  • a relocation engine function taking oldAbs, newAbs, the ordered edit list, and the write function as an explicit parameter — performing the target-absence check, the single atomic rename, the ordered writes, and on failure the best-effort byte restoration plus reverse rename (including the dual-failure "manual attention" error);
  • the permission-preserving write helper, defined exactly once;
  • the temp-file atomic write helper the server's save path uses (same package, preserved temp-file-then-rename semantics).

Both pageops.relocate and mediaops.relocate become thin adapters over the engine. Each package keeps its own error-kind classification (conflict vs internal, driving HTTP status mapping), so the engine must return errors adapters can classify — e.g. a typed/sentinel target-exists error.

Unification caution: pageops today has no explicit pre-rename existence check (collisions are guarded upstream and rename errors are mapped, including a "not empty" string match for directories), while mediaops refuses via Lstat before renaming. Adopting the explicit target-absence check for both is acceptable — it is stricter and still a conflict — but every existing conflict/no-op test in both packages must keep passing.

Rollback testing should gain locality: the crash-safety matrix (write failure mid-phase → restore + reverse rename; reverse rename also failing → manual-attention error) belongs in the new package's own test suite, exercised by injecting a failing writer through the parameter. The existing pageops/mediaops rollback tests may be kept as thinner integration coverage or consolidated; the new package itself must not introduce a package-level seam variable.

Key interfaces:

  • New shared engine package: edit type + Relocate(oldAbs, newAbs string, edits []..., write func(abs string, data []byte) error) error (exact shape up to the agent) + exported keep-mode write helper + exported temp-file atomic write helper.
  • pageops: unexported relocate, seam var writeRelocateEdit, duplicated writeFileKeepMode (also referenced by applyToFile, Reorder's ladder writes, and create's writeLadderStep default — those callers switch to the shared helper).
  • mediaops: unexported relocate, seam var writeMediaEdit, duplicated writeFileKeepMode.
  • server: atomicWriteFile used by the page-save/write path.

Acceptance criteria:

  • One shared engine implements rename + ordered writes + rollback; neither pageops nor mediaops contains its own rollback loop any more.
  • The engine's write seam is a function parameter; the new package has no package-level function-variable seam.
  • The permission-preserving write helper exists exactly once, in the shared package; pageops and mediaops (all call sites, not just relocation) use it.
  • The server's page save uses the shared package's temp-file atomic write helper, with unchanged semantics (same-directory temp file, mode preserved from the existing target, rename over the target).
  • The shared package has its own rollback tests: mid-phase write failure restores prior writes and reverses the rename; a failing reverse rename produces the manual-attention error.
  • Conflict vs internal error classification observable from pageops/mediaops callers is unchanged (existing error-kind tests pass).
  • go test ./... passes.

Out of scope:

  • Any public-API or HTTP-observable behavior change in pageops, mediaops, or the server.
  • Unifying pageops' create-ladder and Reorder write-with-restore loops (no rename involved) into the engine — they should adopt the shared write helper, but their rollback structure stays as is.
  • scaffold/site/AGENTS.md — purely internal refactoring, nothing agent-facing changes.
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Extract the relocation-rollback engine that `pageops` and `mediaops` each implement in parallel into one shared internal package, with the write seam as an explicit parameter, and consolidate the duplicated file-write helpers. **Current behavior:** - `internal/pageops` has an unexported `relocate` engine behind `Rename`/`Move`/`MoveAt`: every edit is precomputed in memory (nothing on disk touched), one atomic `os.Rename` relocates the bundle, then each edit is written through a package-level function variable (`writeRelocateEdit`); on a write failure the files already written are restored from their original bytes (best-effort) and the rename is reversed, with a distinct "the site needs manual attention" error when the reverse rename fails too. - `internal/mediaops` has its own unexported `relocate` behind `RenameFile`/`MoveFile`/`RenameFolder`/`MoveFolder`, mirroring the same discipline with its own seam variable (`writeMediaEdit`) plus an explicit pre-rename target-absence check (`os.Lstat` → conflict error). - `writeFileKeepMode` (overwrite a file preserving its permission bits) is duplicated byte-identically in `pageops` and `mediaops`. - `internal/server` has a third, mechanically different helper, `atomicWriteFile` (temp file in the same directory + rename over the target, preserving mode), used by the page-save path. - In both relocation packages the rollback-test seam is ambient package state: tests swap the package var to force a mid-phase write failure (permission bits can't fail under CI's root runner). **Desired behavior:** A new leaf-level internal package (agent picks the name; it must not import `pageops` or `mediaops` — `mediaops` already imports `pageops`, so the engine has to sit below both) exporting: - an edit type carrying the target absolute path, the new bytes, and the original bytes for restoration; - a relocation engine function taking `oldAbs`, `newAbs`, the ordered edit list, **and the write function as an explicit parameter** — performing the target-absence check, the single atomic rename, the ordered writes, and on failure the best-effort byte restoration plus reverse rename (including the dual-failure "manual attention" error); - the permission-preserving write helper, defined exactly once; - the temp-file atomic write helper the server's save path uses (same package, preserved temp-file-then-rename semantics). Both `pageops.relocate` and `mediaops.relocate` become thin adapters over the engine. Each package keeps its own error-kind classification (conflict vs internal, driving HTTP status mapping), so the engine must return errors adapters can classify — e.g. a typed/sentinel target-exists error. Unification caution: `pageops` today has no explicit pre-rename existence check (collisions are guarded upstream and rename errors are mapped, including a "not empty" string match for directories), while `mediaops` refuses via `Lstat` before renaming. Adopting the explicit target-absence check for both is acceptable — it is stricter and still a conflict — but every existing conflict/no-op test in both packages must keep passing. Rollback testing should gain locality: the crash-safety matrix (write failure mid-phase → restore + reverse rename; reverse rename also failing → manual-attention error) belongs in the new package's own test suite, exercised by injecting a failing writer through the parameter. The existing `pageops`/`mediaops` rollback tests may be kept as thinner integration coverage or consolidated; the new package itself must not introduce a package-level seam variable. **Key interfaces:** - New shared engine package: edit type + `Relocate(oldAbs, newAbs string, edits []..., write func(abs string, data []byte) error) error` (exact shape up to the agent) + exported keep-mode write helper + exported temp-file atomic write helper. - `pageops`: unexported `relocate`, seam var `writeRelocateEdit`, duplicated `writeFileKeepMode` (also referenced by `applyToFile`, `Reorder`'s ladder writes, and `create`'s `writeLadderStep` default — those callers switch to the shared helper). - `mediaops`: unexported `relocate`, seam var `writeMediaEdit`, duplicated `writeFileKeepMode`. - `server`: `atomicWriteFile` used by the page-save/write path. **Acceptance criteria:** - [ ] One shared engine implements rename + ordered writes + rollback; neither `pageops` nor `mediaops` contains its own rollback loop any more. - [ ] The engine's write seam is a function parameter; the new package has no package-level function-variable seam. - [ ] The permission-preserving write helper exists exactly once, in the shared package; `pageops` and `mediaops` (all call sites, not just relocation) use it. - [ ] The server's page save uses the shared package's temp-file atomic write helper, with unchanged semantics (same-directory temp file, mode preserved from the existing target, rename over the target). - [ ] The shared package has its own rollback tests: mid-phase write failure restores prior writes and reverses the rename; a failing reverse rename produces the manual-attention error. - [ ] Conflict vs internal error classification observable from `pageops`/`mediaops` callers is unchanged (existing error-kind tests pass). - [ ] `go test ./...` passes. **Out of scope:** - Any public-API or HTTP-observable behavior change in `pageops`, `mediaops`, or the server. - Unifying `pageops`' create-ladder and `Reorder` write-with-restore loops (no rename involved) into the engine — they should adopt the shared write helper, but their rollback structure stays as is. - `scaffold/site/AGENTS.md` — purely internal refactoring, nothing agent-facing changes.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#116
No description provided.