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¶
- If you passed
WithPollInterval(d)with a positived, that wins outright, including adof exactly2s. - Otherwise, if the backend or filesystem implements
PollIntervalHinterand 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. - 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.
Related¶
- React to changes with hot-reload: the task guide for watching
- Hot-reload safety: why settling and fail-closed reload work as they do
- What survives a write: what the staged-and-renamed commit preserves
- Limitations: the bounds these numbers do not buy you