95 lines
8.6 KiB
XML
95 lines
8.6 KiB
XML
let d = import "adr-defaults.ncl" in
|
|
|
|
d.make_adr {
|
|
id = "adr-022",
|
|
title = "ncl-sync: Nickel Configuration Sync Daemon",
|
|
status = 'Accepted,
|
|
date = "2026-04-16",
|
|
|
|
context = "Every `prvng` CLI invocation that reads configuration runs `nickel export --format json` at least once, often multiple times. There are 124 call sites across the Nu codebase; each export costs 2-5s. The Nu module parse cost (~600-1200ms for 345 files) is a separate problem. This plan targets only the Nickel export cost. `lib_provisioning/config/cache/` already existed with the correct API shape (`cache-lookup`, `lookup-nickel-cache`, etc.) but every function was a no-op — the infrastructure was there but never wired to actual storage. Additionally, `nu_plugin_nickel` already implements file-content-based caching (`nickel-eval` command) but lacked `--import-path` support, which is why all 124 call sites used `^nickel export` directly instead of the plugin.",
|
|
|
|
decision = "A Rust daemon (`ncl-sync`) compiles NCL to JSON proactively and maintains a shared cache at `~/.cache/provisioning/config-cache/`. The daemon and the `nu_plugin_nickel` plugin share a single cache directory and a single key derivation strategy: `SHA256(file_content + sorted_import_paths_joined_by_colon + format)`. This makes the key content-addressed — identical file content produces the same key regardless of path, and the daemon's pre-warmed entries are immediately visible to `nickel-eval` without any coordination protocol. Nu call sites in the hot path replace `^nickel export --format json ... | from json` with `nickel-eval ... --import-path [...]`. For soft-failure call sites (where export failure is acceptable), a `ncl-eval-soft` wrapper in `lib_provisioning/utils/nickel_processor.nu` isolates the single necessary `try/catch` and exposes clean call sites. The daemon is started by `prvng platform start` via `ncl-sync-start` in `service-manager.nu` and stopped by `prvng platform stop`. Nu processes signal re-export needs after mutations by writing `.sync-<pid>.json` sidecar files (atomic rename); the daemon drains these every 500ms.",
|
|
|
|
rationale = [
|
|
{
|
|
claim = "Shared cache dir + content-based key eliminates the need for a socket or IPC between daemon and Nu processes",
|
|
detail = "The plugin's `lookup_cache` reads `~/.cache/provisioning/config-cache/<key>.json` directly from disk. The daemon writes to the same path. There is no runtime coordination — the plugin simply finds the file or falls back to direct `nickel export`. Alternative: daemon exposes a Unix socket for reads (prvng-cli daemon plan) — requires Nu processes to know the socket path, handle connection failures, and adds 10-15ms of socket overhead. The file-based approach gives <5ms reads and zero coupling.",
|
|
},
|
|
{
|
|
claim = "Content-addressed key (SHA256 of file content) is more correct than path+mtime-based key",
|
|
detail = "A path+mtime key would falsely invalidate the cache if a file is touched without content change (e.g. `git checkout`, `touch`). A content-based key ensures that identical NCL files share a cache entry regardless of path, and that the cache only misses when the file actually changed. The tradeoff is that the key computation requires reading the file — mitigated by the daemon doing this proactively at warm-up rather than on each Nu invocation.",
|
|
},
|
|
{
|
|
claim = "Extending nu_plugin_nickel with --import-path is the correct fix for the 124 ^nickel call sites",
|
|
detail = "The plugin existed precisely for this purpose but lacked `--import-path` support, forcing all provisioning code to use `^nickel export` directly. Adding `--import-path` to `nickel-eval` and `nickel-export` unblocks the migration. The plugin already converts JSON to Nu values natively (eliminating `| from json`), handles caching, and preserves error semantics via `LabeledError`.",
|
|
},
|
|
{
|
|
claim = "ncl-sync does not require NATS, SurrealDB, or any platform service",
|
|
detail = "The daemon watches the filesystem via `notify`, runs `nickel` as a subprocess, and writes JSON files. It has no network dependencies. If ncl-sync depended on NATS to function, it would have a bootstrap circularity: NATS is a platform service whose configuration is described in NCL. A config cache daemon cannot depend on the services whose configuration it caches.",
|
|
},
|
|
{
|
|
claim = "Nu processes are never writers to the cache directory",
|
|
detail = "Single-writer principle: only ncl-sync writes `<key>.json` files to the cache. Nu processes write `.sync-<pid>.json` sidecar files as signals to the daemon, then immediately continue execution. The daemon drains sidecars and writes cache entries. This prevents concurrent-write corruption of cache files without requiring locks.",
|
|
},
|
|
],
|
|
|
|
consequences = {
|
|
positive = [
|
|
"prvng component list, workflow list: ~1.5s (from ~3-7s) — Nu module parse only, no nickel export stall",
|
|
"prvng deploy: ~3-5s (from ~15-30s) — multiple nickel exports are cache hits",
|
|
"Cache survives across prvng invocations — warm-up on platform start amortizes the cost for the whole session",
|
|
"nu_plugin_nickel is now usable for all config reads (--import-path gap closed)",
|
|
],
|
|
negative = [
|
|
"Nu startup cost (~1.2s module parse) is unaffected — a separate problem",
|
|
"First invocation of the day: cache cold until daemon warm-up completes (~500ms-2s)",
|
|
"ncl-sync binary must be installed and in PATH for performance benefits; absence degrades gracefully to direct nickel export",
|
|
],
|
|
},
|
|
|
|
alternatives_considered = [
|
|
{
|
|
option = "prvng-cli daemon: route read-only CLI commands to a separate Rust HTTP server via Unix socket",
|
|
why_rejected = "Solves only specific read commands (<100ms), not the general nickel export cost. Adds a second daemon with socket/PID lifecycle. Nu call sites still need output formatting to match Nu tables. Doesn't help operation-path commands that also call nickel export.",
|
|
},
|
|
{
|
|
option = "Lazy-load Nu modules (refactor main_provisioning/mod.nu)",
|
|
why_rejected = "The dispatcher already lazy-loads commands/ subdirectory. The Nu interpreter startup (~200-400ms) is unavoidable regardless. Module parse cost is ~600-1200ms — a real problem but separate from the nickel export stall. This plan targets nickel export; module parse is a future orthogonal improvement.",
|
|
},
|
|
{
|
|
option = "Nu-side cache with file-mtime check (no daemon)",
|
|
why_rejected = "Nu processes are ephemeral — no proactive warming. First command of each session still pays the nickel export cost. Concurrent Nu processes (Makefile, CI) cause cache stampede: multiple processes miss simultaneously and all run nickel export. No file watching — cache becomes stale silently after NCL edits.",
|
|
},
|
|
{
|
|
option = "Separate cache directories for daemon and plugin",
|
|
why_rejected = "Requires a coordination protocol (socket, IPC, or manifest polling) so the plugin can find daemon-written entries. The shared-directory approach eliminates coordination entirely — the key derivation IS the coordination protocol.",
|
|
},
|
|
],
|
|
|
|
ontology_check = {
|
|
decision_string = "ncl-sync Rust daemon + nu_plugin_nickel shared cache at ~/.cache/provisioning/config-cache/ with content-based key SHA256(content+imports+format)",
|
|
invariants_at_risk = ["config-driven-always", "type-safety-nickel"],
|
|
verdict = 'Safe,
|
|
},
|
|
|
|
related_adrs = ["adr-023-ncl-export-wrapper"],
|
|
|
|
constraints = [
|
|
{
|
|
id = "ncl-sync-single-writer",
|
|
claim = "Nu processes NEVER write .json files to the cache directory directly",
|
|
scope = "provisioning/core/nulib/",
|
|
severity = 'Hard,
|
|
check = { tag = 'Grep, pattern = "save.*config-cache.*\\.json", paths = ["provisioning/core/nulib/"], must_be_empty = true },
|
|
rationale = "Single-writer principle: concurrent Nu processes writing cache files would corrupt manifest state and produce partial JSON. Only ncl-sync daemon writes to the cache directory.",
|
|
},
|
|
{
|
|
id = "ncl-sync-no-platform-services",
|
|
claim = "ncl-sync binary must not depend on platform-nats, platform-db, or surrealdb",
|
|
scope = "provisioning/platform/crates/ncl-sync/Cargo.toml",
|
|
severity = 'Hard,
|
|
check = { tag = 'Grep, pattern = "platform-nats|platform-db|surrealdb", paths = ["provisioning/platform/crates/ncl-sync/"], must_be_empty = true },
|
|
rationale = "Bootstrap circularity: NATS and SurrealDB are platform services whose configuration is managed by ncl-sync. The daemon cannot depend on services it configures.",
|
|
},
|
|
],
|
|
}
|