fix(codegen): write generated files atomically to survive interrupted generation#4262
fix(codegen): write generated files atomically to survive interrupted generation#4262webel-crew wants to merge 6 commits into
Conversation
… generation Generate() unlinked the exec + model outputs at the very start, before any schema load or render, so any interruption or failure during generation (OOM on the validation compile, a timeout, SIGKILL, or a failing schema mutator) left the output ABSENT — invisible to go build until the next regen. Reported as 99designs#2345 ('deleted the older model/generated.go file, did not create a new one') and 99designs#3505 ('it seems to delete the generated files'), both closed without addressing the delete-before-write. Replace the upfront unlink + truncate-write with an atomic write: render to a temp file in the same directory, then os.Rename into place on success. os.Rename is atomic on the same filesystem, so a reader/build never observes a half-written file, and a mid-generation failure leaves the pre-existing file intact instead of absent. The upfront unlink is removed because the rename replaces the file atomically (no prior delete needed). Add TestGenerateAtomicWritePreservesOutputOnFailure: a pre-existing generated file survives a mid-generation failure (fails on the old unlink-first code, passes with the fix).
|
Ok, I'm comparing this to implementations like: |
Address maintainer review (99designs#4262) comparing the atomic write to google/renameio, natefinch/atomic, tailscale/atomicfile and moby/sys. Two gaps: 1. Permissions regression: os.CreateTemp makes the temp 0o600 and os.Rename carries that mode, so every regenerated file silently dropped 0644 -> 0600. Now chmod the temp to the existing destination's mode (0o644 default for a new file, matching the pre-change os.WriteFile mode), so regens don't churn modes. 2. Durability: fsync the temp before the rename so a power loss can't expose a zero-length file even when the rename succeeded (per ext4's Ts'o). Matches renameio/natefinch/tailscale/moby. Added unit tests covering mode preservation, the 0o644 default for new files, no leftover temp files, and that the unchanged-content short-circuit still preserves mtime for the Go build cache.
|
Thanks — reading those five against this change was the right prompt, and it surfaced two real gaps, both now fixed in Where this already matched. All five write to a temp in the same directory (so Gap 1 — permissions regression (fixed). Gap 2 — Dep vs inline. Kept inline/dependency-free to match gqlgen's no-file-IO-deps style and the ~20-line scope of the lighter refs (tailscale's is explicitly internal; moby's is heavier than this needs). If maintainers would rather take a vetted dep, Tests. Added |
CI on 99designs#4262 failed on windows + fmt-and-lint, exposing real gaps in the atomic-write change (all four are genuine, not flaky): 1. test (windows-latest, *): os.Rename over an existing file can fail TRANSIENTLY on Windows with "Access is denied" (MoveFileEx contends with a virus scanner / search indexer briefly holding an open handle on the destination). This broke TWO PRE-EXISTING tests (TestTemplateOverride, TestRenderFS) that windows CI was passing before this PR. Fixed with a short, bounded retry-on-EPERM loop around the rename (renameWithRetry) — the same workaround natefinch/atomic and tailscale/atomicfile ship for this exact Windows quirk; a no-op on non-windows (the common case still does exactly one os.Rename). 2. My own new tests (TestWriteIsAtomicPreservesPermissionsAndLeavesNoTemp, TestWriteIsAtomicAndUnchangedShortCircuit) asserted EXACT POSIX mode bits (0o644/0o600), which are meaningless on Windows — it has no real owner/group/other permissions (os.FileMode there is a coarse read-only-attribute emulation). google/renameio's own writefile.go is `//go:build !windows` for the same reason. Gated the POSIX-mode assertions on runtime.GOOS != "windows"; the platform-agnostic leftover-temp-file check still runs everywhere. 3. fmt-and-lint (1.25): three real golangci-lint findings, all mine: - api/generate.go: golang.org/x/tools/imports was in the stdlib import group instead of its own third-party group (gci). - codegen/templates/templates_test.go: writeContent's two string params should be combined (gocritic paramTypeCombine). - api/generate_test.go: two lines over the repo's 100-char golines limit. Verified clean against gofumpt, golines --max-len=100, and gci with the repo's exact section config (standard/default/prefix). Verified: go build ./... clean; go test ./codegen/templates/... ./api/... green; gofumpt/golines/gci all report no changes needed.
test (windows-latest, *) still failed after the rename-retry fix (ced36a9/b7ddf535) with the same "Access is denied" on TestTemplateOverride and TestRenderFS specifically - my retry loop only helps a TRANSIENT external lock (an AV scanner, a search indexer), but this one is deterministic and self-inflicted: both tests do f, err := os.CreateTemp(...) defer f.Close() Render(Options{..., Filename: f.Name(), ...}) and keep f OPEN (via the deferred Close) while Render's write() tries to atomically replace f.Name() out from under it. On Windows, file replacement is a mandatory-locking operation - you cannot rename/replace a file that this same process still has an open handle on - unlike POSIX, where renaming over an open file is completely fine (the old inode just stays alive under the open fd). The pre-change code called os.WriteFile directly on the open path, which Windows DOES allow (in-place content overwrite, not a rename), so this never surfaced before. Both tests only need f.Name() after creation (TestRenderFS reads the result back fresh via a separate os.ReadFile, not through the open handle), so the fix is to close f immediately after CreateTemp, before calling Render - no change to what either test actually verifies. Verified: go build ./... clean; go test ./codegen/templates/... ./api/... green (both tests pass, including the pre-existing "gofmt failed on gqlgen<N>: ... found 'import'" log line from Render's own template smoke-test content, which is expected noise, not a failure - both tests still report PASS around it, same as before this PR). Separately: fmt-and-lint (1.25)'s check-generate step is panicking with "unable to find type: <package>.<DifferentTypeEachRun>" while fmt-and-lint (1.26) and golangci-lint (both versions) are green. This doesn't look connected to this PR: the panic is inside gqlgen's own codegen.buildInterface/BuildData type-merging across federation/ testserver fixture packages this PR's diff (codegen/templates/ templates.go's file-write internals) doesn't touch, a DIFFERENT type fails to resolve on each run (my first commit 6dca944 hit this same class before any of these template.go changes existed, on the exact same job), and it's isolated to one Go version's matrix leg. It has the signature of `go generate ./...` racing itself across gqlgen's many interdependent testdata packages under Go 1.25 specifically. I don't have a fix for that (it's outside this PR's diff and I can't reproduce Windows/the exact runner locally to bisect further) - flagging it for maintainer visibility rather than guessing at a change to unrelated code.
|
I don't think you understand some of the nuances of the implementations I linked to. This will write to a temporary file first, then do an atomic rename to the final destination. The temporary file is created in the same directory as the target, to give the best chance of being able to do an atomic rename. You can’t do an atomic rename across filesystem boundaries; using the same directory ensures both files are on the same filesystem. To handle concurrent writes, I normally insert a random UUID into the temporary filename, so different processes write to different tempfiles. This is handled automatically by Go’s os.CreateTemp function, which adds a random string to the end of the filename. The
This is the windows file: Which then calls this fileto make the sys call: And this is the not windows file: |
Addresses @StevenACoffman's feedback on the tailscale/atomicfile reference (99designs#4262 (comment)): tailscale's WriteFile explicitly does if runtime.GOOS != "windows" { f.Chmod(perm) } i.e. permission handling is SKIPPED on Windows, not attempted with a default value. My prior code called tmp.Chmod(perm) unconditionally on every platform. That's not a correctness bug (Chmod on Windows just toggles the read-only attribute off, since it has no real owner/group/ other bits to set to 0644), but it's not the tailscale contract either - Windows doesn't have the concept these bits are trying to express, so attempting the call there is a category error even when harmless, and I'd represented the comparison as more careful than it was. This gates the whole Chmod call (and the perm-resolution stat) on runtime.GOOS != "windows", matching tailscale's own code exactly, with the reasoning written down inline (and a link back to the review comment) so a future reader isn't left to rediscover why. Confirmed while re-checking this: os.Rename on Windows already calls MoveFileEx(MOVEFILE_REPLACE_EXISTING) internally (internal/syscall/ windows.Rename) - the exact same underlying API natefinch/atomic's ReplaceFile wraps - so it is not a weaker primitive than the alternatives; what it lacks is retry, which is what renameWithRetry (the previous commit) already adds. Documented that in the retry function's comment so the choice not to switch to a raw syscall isn't left unexplained. Verified: go build ./... clean; go test ./codegen/templates/... ./api/... green (including TestWriteIsAtomicPreservesPermissionsAndLeavesNoTemp, TestWriteIsAtomicAndUnchangedShortCircuit, TestTemplateOverride, TestRenderFS, TestGenerateAtomicWritePreservesOutputOnFailure); gofmt/go vet/golines/gci all clean.
… unlinking them
Root cause of the check-generate CI failures on this PR, found by running
`go generate ./...` locally: base and master pass, every commit of this
branch failed deterministically (the "different type each run" in CI was
map-iteration order picking which missing type to report first — the
failure itself was 100% reproducible).
The old upfront `syscall.Unlink` of the exec + model outputs was not
redundant — it was LOAD-BEARING for every config that autobinds the
package its models are generated into (all codegen/testserver fixtures,
the federation examples):
1. old: unlink first → stale models file GONE at schema-load time →
autobind finds nothing → modelgen generates all types → OK.
2. this branch (before this commit): stale models file VISIBLE at
load → autobind binds the previously-generated types as if they
were user-written models → modelgen SKIPS them → the freshly
written models file loses them → the exec build reload fails with
"unable to find type: <pkg>.<Type>".
But the unlink is also exactly what 99designs#2345/99designs#3505 are about: delete-then-
interrupted leaves the user with no generated file at all. Both
properties need to hold, so replace the destructive unlink with a
NON-destructive loader mask:
- internal/code.Packages gains an Overlay (packages.Config.Overlay)
threaded into every Load, plus MaskFile/UnmaskFile.
- api.generate masks each existing output (exec + model) with a
package-clause-only stub read from the file's own package clause
(empty bytes would be a parse error and break loading the rest of
the package). Missing/unparseable files are left alone — nothing
stale to bind, matching the old unlink's no-op there.
- config.Config owns the overlay map (MaskGeneratedFile) because
LoadSchema RECREATES c.Packages mid-generation — an overlay set
only on the first instance would silently vanish. The map is shared
by reference with every instance the Config creates.
- templates.write unmasks each file once its new contents are on disk
(including the unchanged-content short-circuit path): from that
moment disk is truth, and the exec build's reload right after
modelgen must see the just-generated types, not the stub.
Disk state is now only ever changed by the atomic rename — kill
generation at any instant and the previous outputs are intact — while
the loader sees the same "outputs don't exist yet" world the old unlink
provided.
Verified:
- `go generate ./...` (the exact check-generate CI command): failed
with 22 "unable to find type" errors before, exits 0 with this
commit, run twice back-to-back with a clean tree both times
(regenerated output identical to committed).
- The deterministic single-package repro (codegen/testserver/
followschema: `rm -f resolver.go && go run testdata/gqlgen.go
-config gqlgen.yml -stub stub.go` on a clean tree) fails without
this commit and passes with it; models-gen.go regenerates at full
436 lines instead of being emptied to a bare package clause.
- api, codegen/templates, internal/code, codegen/config suites green;
gofmt/vet/golines/gci clean.
No standalone regression test is added: a minimal self-autobind fixture
does NOT reproduce (the trigger needs the testserver fixtures' richer
interface/implementor topology), and the check-generate CI step already
runs the full corpus — it is the deterministic gate that caught this.
|
CI is fully green now. The The upfront The fix ( Verified with No standalone regression test in the diff: I built a minimal self-autobind fixture and it does not reproduce — the trigger needs the testserver fixtures' richer interface/implementor topology — and Earlier in the thread I suggested this looked like a pre-existing flake — that was wrong, based on a local bisect against base/master. Happy to adjust the overlay approach if you'd rather see it shaped differently. |
api.Generateunlinks the exec + model output files (syscall.Unlinkoncfg.Exec.Filename/cfg.Model.Filename) at the very start of generation, but the rewrite only happens much later intemplates.Render→os.WriteFile. If generation is interrupted anywhere in that window — a build/compile failure, a timeout, an OOM on a largegenerated.go, a killed process — the output file is left deleted rather than merely stale. That's invisible togo builduntil the next successful regen, and reported in #2345 (and as a symptom in #3505).This makes the write atomic and drops the upfront unlink:
templatesrenders to a temp file in the same directory andos.Renames it into place on success, so an interruption leaves the previous file intact instead of absent, and a reader never sees a half-written file. Removing thesyscall.Unlinkis safe becauseos.Renamealready replaces the file atomically (andos.WriteFiletruncates) — the pre-delete was redundant. Successful runs are unchanged, including the unchanged-content short-circuit.Added
TestGenerateAtomicWritePreservesOutputOnFailure(fails onmaster, passes here); the existingapiandcodegen/templatessuites still pass.I have: