Skip to content

feat(schema): TTL fast-path skips per-invocation /api/schema/ fetch#35

Merged
thomaschristory merged 2 commits into
mainfrom
feat/schema-ttl-fastpath
May 7, 2026
Merged

feat(schema): TTL fast-path skips per-invocation /api/schema/ fetch#35
thomaschristory merged 2 commits into
mainfrom
feat/schema-ttl-fastpath

Conversation

@thomaschristory
Copy link
Copy Markdown
Owner

Summary

  • Resolves SKILL.md optimization #34. Stops the GET /api/schema/ between every PATCH that surfaced in the issue logs.
  • Wires up the long-dormant Defaults.schema_refresh config field as a real TTL fast-path: daily (new default, 24h), weekly (7d), manual (∞), on-hash-change (legacy, fetch every call).
  • Adds nsc --refresh-schema <subcmd> for one-shot forced refresh bypassing the TTL.
  • SKILL.md gains a Performance section guiding agents to prefer one filtered list --all over N get calls and one NDJSON --apply over shell loops, plus documents the new TTL behaviour.

Behaviour change

Default policy moves from on-hash-changedaily. Users with explicit defaults.schema_refresh in their config keep their setting. The previous default was the source of the wasted round-trips; the new default matches what kubectl does for discovery cache.

Test plan

  • 12 new tests in tests/schema/test_source_ttl.py covering: fresh/stale cache under daily, manual ignores age, force_refresh bypass, on-hash-change preserved, no-cache bootstrap, schema-override wins, default constant, and per-policy TTL values.
  • Existing test_cache_hit_skips_rebuild still asserts legacy fetch-every-time when the policy is on-hash-change.
  • just lint passes (ruff + ruff format + mypy --strict).
  • just test — 600 passed, 1 skipped.
  • nsc --help shows the new --refresh-schema flag.

🤖 Generated with Claude Code

Resolves #34.

Before: every `nsc` invocation fetched `/api/schema/` from NetBox so the
cached CommandModel could be hash-compared, even when the cache was
seconds old. NetBox access logs showed a `GET /api/schema/` between
every PATCH from agent-driven bulk updates — pure waste.

After: `Defaults.schema_refresh` (already a config field, never wired)
controls a TTL fast-path:

  - `daily` (new default) — trust the cache for 24h between live fetches
  - `weekly` — trust for 7 days
  - `manual` — trust indefinitely
  - `on-hash-change` — legacy behaviour, fetch every invocation

Adds `nsc --refresh-schema <subcmd>` to force a one-shot live fetch
bypassing the TTL.

The SKILL.md gains a "Performance" section guiding agents to prefer one
filtered `list --all` over N `get` calls and one NDJSON `--apply` over
shell loops.

Behaviour change: the default policy moves from `on-hash-change` to
`daily`. Users who pinned `schema_refresh: on-hash-change` in their
config keep the legacy behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thomaschristory thomaschristory added this to the v1.0.2 milestone May 6, 2026
…tch, partial writes

Adversarial review of #35 surfaced four P1s and two P2s. Address all:

P1.1 mtime-based freshness is fragile. A `touch`, backup-restore, or
`cp -p` could fake freshness, and a clock that jumped forward then back
could keep a stale entry "fresh" indefinitely. Replace with an explicit
`<hash>.meta.json` sidecar carrying `{"fetched_at": <epoch>}`. Reject
entries whose sidecar is more than 60s in the future. Pre-existing cache
files without a sidecar are treated as stale (one-time refetch on upgrade).

P1.2 fast path skipped hash validation. `_find_fresh_cached` and
`_find_any_cached` previously did `CommandModel.model_validate_json` on
the raw cache file without verifying that the file's embedded
`schema_hash` matches the filename. A tampered or copied JSON file would
be trusted on the DAILY path. Both helpers now route through
`CacheStore.load`, which already enforces the hash check.

P1.3 cache writes were non-atomic. `CacheStore.save` did
`Path.write_text` directly; a concurrent reader could observe a partial
file. Now writes via temp-file + `os.replace`. Same for the sidecar.
`prune_orphans` deletes the sidecar alongside its cache file.

P1.4 default-flip had no migration note. Add Unreleased section to
CHANGELOG.md documenting the `on-hash-change -> daily` change, the
`--refresh-schema` escape hatch, and the one-time refetch on upgrade.

P2.5 `docs/architecture/schema-loading.md` listed `on-hash-change` as
the default and referenced a nonexistent `nsc refresh` command.
Rewritten to reflect the TTL fast-path, sidecar layout, hash validation,
and `--refresh-schema`.

P2.6 added an end-to-end CLI test exercising the brittle wire-through
that unit tests for `force_refresh=True` could not prove: Typer parsing
-> `_extract_global_overrides` -> `CLIOverrides` -> `build_runtime_context`
-> `resolve_command_model`. Proves DAILY default uses the fast-path on a
warm cache and `--refresh-schema` actually bypasses it.

New tests:
- sidecar-missing forces refetch under DAILY (upgrade path)
- future-dated sidecar (>60s) is rejected
- fast path rejects hash mismatch on cache file content
- `CacheStore.save` writes a sidecar with a numeric `fetched_at` near now
- `load_fetched_at` returns None when missing or corrupt

608 tests pass; ruff + ruff format + mypy --strict clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thomaschristory thomaschristory merged commit aff8b3c into main May 7, 2026
10 checks passed
@thomaschristory thomaschristory deleted the feat/schema-ttl-fastpath branch May 7, 2026 07:00
thomaschristory added a commit that referenced this pull request May 7, 2026
… self-heals (#42)

Resolves #39.

Before: when `_build_and_cache` fetched `/api/schema/` and the live hash
matched an existing cache file, it returned the cached CommandModel
without bumping the `<hash>.meta.json` sidecar. An aged-out sidecar — or
a legacy cache from before #35 — therefore never gained proof of
freshness, so the TTL fast-path failed on every subsequent invocation
and `nsc` refetched `/api/schema/` between every command, exactly the
behaviour #34 was supposed to eliminate.

After: `CacheStore.touch_fetched_at` rewrites just the sidecar (no
re-serialize of the cache file), and `_build_and_cache` calls it on the
cache-hit-by-hash branch. First post-upgrade invocation refetches once,
seeds the sidecar, and the fast path is active from there.

Tests cover four `touch_fetched_at` cases (refresh, legacy seed,
no-cache no-op, invalid hash) and two end-to-end scenarios via
`resolve_command_model` (legacy cache without sidecar, aged cache with
unchanged hash).

Co-authored-by: Thomas Christory <tchristory@partner.auchan.fr>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

SKILL.md optimization

1 participant