Skip to content

fix: make SavedGraph.Save work on Windows - #24

Open
spagu wants to merge 1 commit into
coder:mainfrom
tradik:portable-atomic-save
Open

fix: make SavedGraph.Save work on Windows#24
spagu wants to merge 1 commit into
coder:mainfrom
tradik:portable-atomic-save

Conversation

@spagu

@spagu spagu commented Aug 24, 2026

Copy link
Copy Markdown

The problem

hnsw does not compile for Windows:

$ GOOS=windows GOARCH=amd64 go build ./...
encode.go:304:14: undefined: renameio.TempFile

github.com/google/renameio is Unix-only — TempFile is not defined on Windows in either v1 or v2 (google/renameio#10). Since Go compiles a package as a whole, this blocks the entire hnsw package on Windows, not just SavedGraph. Callers that only use Graph, NewGraph and Node — never touching SavedGraph — cannot build either. That is how I ran into it: adding a Windows target to a project that embeds hnsw as its vector index.

renameio is also the package's only non-test dependency.

The change

SavedGraph.Save is the single call site. It is replaced with the same sequence on the standard library:

renameio standard library
renameio.TempFile("", g.Path) os.CreateTemp(filepath.Dir(g.Path), filepath.Base(g.Path)+".tmp-*")
tmp.Cleanup() tmp.Close() + os.Remove(tmpName) in a defer
tmp.CloseAtomicallyReplace() tmp.Sync() + tmp.Close() + os.Rename(tmpName, g.Path)

The temporary file stays in the target's directory, so the rename never crosses a filesystem boundary.

The crash-safety property is unchanged. os.Rename replaces an existing file on every platform Go supports — on Windows it compiles to MoveFileEx with MOVEFILE_REPLACE_EXISTING — so an interrupted save still leaves the previous graph in place rather than a truncated one. The fsync is kept, and kept before the rename: a rename that reaches the disk ahead of the data it points at is what turns a crash into the loss of both graphs rather than one.

Tests

Added TestSavedGraph_ReplacesAndLeavesNoTempFiles, covering the two properties that are platform-dependent here: a second Save replaces the first graph rather than failing on an existing file, and no temporary file survives.

Verified the test has teeth by mutation — removing the os.Rename fails it:

--- FAIL: TestSavedGraph_ReplacesAndLeavesNoTempFiles
    Messages: the second save should have replaced the first

I did not add a test for the fsync. Its effect is only observable across a power loss, so a unit test asserting it would assert nothing; removing the Sync call leaves the suite green. It is retained because it is the reason renameio was here.

go test ./...                            ok
go vet ./...                             ok
gofmt -s -l .                            clean
GOOS=windows GOARCH=amd64 go build ./... ok
GOOS=windows GOARCH=amd64 go vet ./...   ok
GOOS=darwin  GOARCH=arm64 go build ./... ok
GOOS=linux   GOARCH=arm64 go build ./... ok

Existing behaviour on Unix is unchanged, and TestSavedGraph still passes.

SavedGraph.Save is the package's only use of github.com/google/renameio,
and renameio does not build on Windows — neither v1 nor v2 defines TempFile
there. Because Go compiles a package as a whole, that makes the entire hnsw
package uncompilable for GOOS=windows, including for callers that never
touch SavedGraph:

    $ GOOS=windows GOARCH=amd64 go build ./...
    encode.go:304:14: undefined: renameio.TempFile

This replaces the call with the same sequence on the standard library:
create a temporary file in the target's directory, write, fsync, close,
rename over the target. os.Rename replaces an existing file on every
platform Go supports — on Windows it is MoveFileEx with
MOVEFILE_REPLACE_EXISTING — so the crash-safety property is unchanged:
an interrupted save leaves the previous graph in place rather than a
truncated one.

The fsync is kept, and kept before the rename. A rename that reaches the
disk ahead of the data it points at is what turns a crash into the loss of
both graphs rather than one.

Adds a test for the two properties that differ by platform: a second Save
replaces the first graph, and no temporary file survives. Verified by
mutation — removing the rename fails the test.

Drops the only non-test dependency in go.mod.

    go test ./...                            ok
    go vet ./...                             ok
    GOOS=windows GOARCH=amd64 go build ./... ok
    GOOS=windows GOARCH=amd64 go vet ./...   ok
spagu added a commit to tradik/mddb that referenced this pull request Aug 24, 2026
github.com/coder/hnsw does not compile for Windows. Its SavedGraph.Save uses
github.com/google/renameio, which has no Windows build in either v1 or v2 —
verified against both, not assumed:

    GOOS=windows go build   undefined: renameio.TempFile

Go compiles a package as a whole, so this applied to us even though MDDB never
calls Save. We persist vectors ourselves in internal/vector/vector_store.go and
use four symbols from that package: Graph, MakeNode, NewGraph, Node. One
unreachable function was making the whole target impossible.

Fixed upstream rather than worked around: coder/hnsw#24 rewrites that call on
the standard library — os.CreateTemp in the target's directory, fsync, then
os.Rename over the target. Crash safety is unchanged, because os.Rename
replaces an existing file on every platform Go supports; on Windows it compiles
to MoveFileEx with MOVEFILE_REPLACE_EXISTING. The fsync stays before the
rename, which is the part that matters: a rename reaching the disk ahead of its
data loses both graphs instead of one. Upstream CI is green on the PR.

Until it merges, go.mod carries a replace onto a fork holding exactly that
commit — the fork is an unmodified mirror of the PR branch, no module rename
needed, because Go checks the replacement's declared module path against the
left side of the directive. go.sum pins the commit hash, so a force-push to the
fork fails the build loudly rather than substituting code silently. A replace
applies only to the main module, so nothing downstream inherits it. Removal is
tracked as WIN-005.

Drops github.com/google/renameio from the module graph entirely; LICENSE_AUDIT
updated (275 dependencies, was 276).

This does not make MDDB build on Windows. GOOS=windows now fails on
syscall.Statfs and syscall.Getrusage in incident_detector.go,
persistence_check.go and system_handlers.go — which is WIN-001, the next task.
It removes the blocker standing in front of that work.

    go build ./... (linux)                   ok
    go test ./...                            ok, 32 packages
    go test -race ./internal/vector/...      ok, 37.4s
    go vet / gofmt -s / golangci-lint        clean, 0 issues
    go mod download && verify && tidy        ok (the release.yml steps)
    govulncheck                              0 affecting this code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant