The .lock rename as a publish protocol
Publishing a file atomically is the writer’s half. It writes to file.lock, finishes it completely, then renames it to file. Rename within a filesystem is atomic, so a reader opening the final name sees either the old file or the complete new one. A filesystem watcher has a separate problem: one logical publish arrives as several low-level events against both names.
In the snap-gs supervisor the fsnotify wrapper knew about .lock. Its filter chain rewrote lock events back into logical ones:
func LockNames(events []Event, err error) ([]Event, error) {
for i := range events {
if name := strings.TrimSuffix(events[i].Name, ".lock"); name != events[i].Name {
events[i].Name, events[i].Op = name, fsnotify.Create
}
}
return events, err
}Watch collects events for 200 milliseconds before running its filters. LockNames rewrites foo.lock to foo, and SameNames then merges every event with that logical name. For the small control-file writes this watcher handles, the staging create and final rename land in one batch and the callback gets one Create of foo. The private staging name never escapes into the control logic.
That batching is part of the contract. LockNames alone does not make an arbitrarily long write notification-safe: if a batch flushes before the rename, the callback can look for foo while only foo.lock exists. The atomicity of readers opening the final name still comes from rename; the watcher filter is a coalescing convenience sized for these small specification files, not a universal publish protocol.
On the writer side, the ordering has a subtlety worth stating, because Go’s defers run in reverse:
defer os.Rename(lock, file)
defer xattr.Set(lock, "user.s3sync.meta", bs)
defer w.Close()Read bottom to top for execution order: close the file, set the extended attribute, then rename. The rename is written first precisely so it runs last—the xattr must land on file.lock before the rename publishes it. Set afterward, it targets a name that no longer exists and fails silently, leaving the published file with no metadata.
The writer discarded every deferred return value. Rename still ran after a failed gzip close, file close, or xattr write, and after an earlier marshal or write error. It needed explicit success checks before publishing the final name.
A real commit boundary must finish the encoder, close the file, attach the xattr, and check each result in sequence. Only then may it rename; any failure leaves or quarantines the .lock. Reverse defer ordering is necessary to read the intent of this source, but checked preconditions are what make the intent a protocol. The xattr note carries the full defect.
The same convention shows up at every layer, in whatever language that layer speaks. In the install shell script it is a bare mv:
sudo mv /opt/snap-gs/SnapshotVR/$i/env{.lock,}And in a systemd unit it is an ExecStartPre:
ExecStartPre=/usr/bin/cp -a .../sync.sh .../%i/sync.sh.lock
ExecStartPre=/usr/bin/mv .../%i/sync.sh.lock .../%i/sync.shThe same naming convention connected the Go daemon, shell scripts, and unit file. Publishing only after successful writes and validation would let final-name readers consume complete files without watching the writer.