Skip to content

Defaults and limits

Every number this module chooses on your behalf, in one place.

Watching and reload timing

Constant Value Governs Overridden by
DefaultPollInterval 2s How often a polling watcher checks a source that cannot notify natively. WithPollInterval(d) on Store.Watch, or a backend or filesystem implementing PollIntervalHinter.
DefaultSettleInterval 250ms How long a burst of change reports is allowed to settle before the store reloads once. WithSettleInterval(d) on Store.Watch.
(unexported) burst bound 4 times the settle interval The longest settling may defer a reload, however long a burst runs. Nothing.

Both options are WatchOption values for Store.Watch. NewStore does not accept them.

Store.Watch watches every WatchableBackend: local files, and also the remote adapters that implement it. Environment variables, flags and in-memory sources are not watched, because nothing outside the process changes them.

How the poll interval is chosen

  1. If you passed WithPollInterval(d) with a positive d, that wins outright, including a d of exactly 2s.
  2. Otherwise, if the backend or filesystem implements PollIntervalHinter and hints a positive duration, the hint is used. A remote object store, where every poll is a billed API call, asks for minutes rather than the two seconds a local file wants.
  3. Otherwise, DefaultPollInterval.

NewWatcher follows the same order. A positive interval is used as given, and a non-positive one means none was chosen, so the filesystem's hint applies, or the default.

The store's interval does not reach every backend. AWS Secrets Manager, Azure Key Vault, GCP Secret Manager and Vault ignore it and poll on their own cadence, set with the WithPollInterval option on that adapter's constructor. The Consul backend does not poll on a timer: the interval becomes the wait of each blocking query. etcd ignores the interval and receives pushed events. Each adapter's how-to page states which applies.

The settle window

A logical configuration change is not always one filesystem event. A deploy replacing two overlays, a config-management run rewriting a directory, or an operator saving two files each produces separate events, and nothing says they were meant as one. Without a window, observers run once per event, and the first run sees a combination nobody intended.

Every report from a watched source starts or extends the window. When it expires, the store reloads once. The reload then compares values, and observers are told only if something actually changed.

The window is bounded. A pure "wait for quiet" window never fires while changes keep arriving faster than the window, so a file rewritten continuously would tell observers nothing at all. The reload therefore happens within four settle intervals of the first event in a burst, which is one second at the default.

WithSettleInterval(0) disables settling and reloads on each report. A store watching through an injected watcher (WithWatcher) uses no window unless you set one, so a test's trigger and the reload stay in step without waiting on a timer.

Writes made through the store itself never wait on the window. Apply knows the extent of its own batch and notifies directly.

Settling does not make a multi-file change atomic. Writes spaced further apart than the window are still seen as separate changes. That guarantee belongs to whoever writes the files, by writing them atomically or by keeping settings that change together in one file.

File modes and write staging

Thing Value Notes
Mode of a config file this module creates 0600 Owner-only. Configuration routinely holds credentials, so a permissive default would leak them.
Mode of a config file that already exists Unchanged Whatever mode its owner chose is their decision. Staging through a temporary file must not quietly tighten it.
Mode of a directory this module creates 0750 Created only when writing into a directory that does not exist yet.
Staging file name <target>.<pid>-<counter>.yamldoc-tmp Written beside the target and renamed over it. Unique per write, so two writers in one process cannot stage over each other.
Symbolic-link hops followed before writing 16, with cycle detection A configuration file managed by a dotfile tool is routinely a symlink into a tracked repository, and a rename would replace the link rather than write through it.

Conflict detection compares a SHA-256 hash of the content, not the modification time. Timestamp granularity varies and is coarse on some filesystems, so an edit that preserves a file's length within one tick, such as changing info to warn, is invisible to a stat-based comparison. The polling watcher hashes for the same reason.

Paths under config.Dir

config.Dir(path) returns a filesystem confined to a directory. Pass "config.yaml", not "/etc/app/config.yaml".

An absolute path is treated as an attempt to escape and fails with path escapes from parent rather than fs.ErrNotExist. The distinction matters to the store, which treats a missing optional source as normal and anything else as fatal, so an absolute path turns a file that is merely absent into a hard failure.

Containment is enforced by the operating system through os.Root, not by a check this module performs, so .., an absolute path and a symlink pointing away are all refused. The root is opened per operation rather than held open, which costs one extra openat per call and avoids a file-descriptor leak that surfaced only at scale.

Sizes

View.GetSizeInBytes reads a human-written size. Suffixes are binary, so each step is a multiple of 1024, not 1000:

Written Bytes
1024 (no suffix) 1024
1b 1
1kb 1,024
1mb 1,048,576
1gb 1,073,741,824
1tb 1,099,511,627,776

Matching is case-insensitive and surrounding whitespace is trimmed, so "10 MB" works. A fractional number is accepted and truncated toward zero, so "1.5kb" is 1536. A negative number, or anything that cannot be read as a size, returns 0, which is the same answer the other accessors give for a value they cannot convert.

There is no kib/mib spelling and no decimal (1000-based) mode. kb is 1024.

Caching

SchemaOf[T]() called with no options caches its result per type, so repeated calls do not re-reflect. A call that passes options, such as WithStrictMode(), builds fresh and is not cached, because an option can change the result.

Nothing else in this module caches. A View is a pointer copy over an immutable snapshot, so taking one is not a load.

Concurrency

Store serialises its own access internally. Concurrent loads and writes cannot interleave, so a reader never observes a partially applied change and two writers cannot both decide to write and then overwrite one another.

A Snapshot is immutable and a View performs no I/O, so both are safe to share across goroutines without further synchronisation.