Skip to content

fix: hot-load file changes on every sync in long-running services - #47

Open
matthewscobell wants to merge 11 commits into
mainfrom
fix/hot-load-data-refresh
Open

fix: hot-load file changes on every sync in long-running services#47
matthewscobell wants to merge 11 commits into
mainfrom
fix/hot-load-data-refresh

Conversation

@matthewscobell

Copy link
Copy Markdown
Contributor

What kind of change is this?

  • Bug fix

Problem

A customer running baton-file as a long-lived Windows service reported that file edits were not reflected in their tenant until the service was restarted. The connector parsed the input file exactly once — inside ResourceSyncers(), which the SDK invokes a single time at connector construction — and every subsequent sync served resources/entitlements/grants from that frozen in-memory cache. Ironically, Validate() re-read and re-validated the file on every sync, then discarded the result into a field nothing consumed.

This regressed in #40 (the connector redesign). Before #40, List/Entitlements/Grants re-read the file on every call, so hot-load worked by construction. One-shot CLI runs masked the regression because the process exits after each sync.

Fix

  • Validate() — the only per-sync hook the SDK gives this connector — now rebuilds the sync cache from the file's current contents and publishes it atomically (cacheHolder wrapping atomic.Pointer[syncCache]) to the resource builders. Data-section edits are picked up on the next sync; schema changes (a brand-new resource type) still require a restart by design, since the SDK registers resource types once at startup.
  • Generation-stamped page tokens (<file-content-fingerprint>:<offset>): if a listing's token was minted against a different cache generation (mid-sync health-probe revalidation, or a sync resumed after a restart with a changed file), the listing restarts instead of replaying offsets into changed slices — which would silently skip or duplicate rows. Restarting is safe because c1z writes are idempotent upserts. Legacy bare-numeric tokens are still honored so in-flight syncs survive a binary upgrade.
  • Nil-cache guards in List/Entitlements/Grants return an error where a nil cache would previously have panicked.
  • An invalid file edit now fails the sync with the real validation error while the last-known-good data keeps serving (previously the failure mode was a stale cache plus a confusing no resource builders found).
  • Fixes a latent race on main: Validate() wrote the plain validatedData field from concurrently served RPCs (e.g. health checks) with no synchronization.

Not a breaking change

  • Restores documented/intended behavior (pre-Connector Redesign, Refactor, and Extension of Resource Traits #40); per review-breaking-changes.md, behavior-preserving changes and performance work are explicitly non-breaking. Full e2e verification: all 12 example inputs (every test/integration/testdata/ fixture and every template — YAML/JSONC/CSV/XLSX, full + quickstart) synced with a main-built and branch-built binary produce semantically identical resources/entitlements/grants dumps and identical capabilities JSON. The external-grants (shared identity source) annotations are byte-identical.
  • baton_capabilities.json and config_schema.json are unchanged; no flag/config surface changes.

Lint (pre-existing failure on main)

verify/lint is currently red on main: 7 staticcheck SA1019 hits from the recent SDK bump deprecating trait-level options. This PR silences them with explained //nolint:staticcheck comments instead of migrating, deliberately: the deprecated options populate both the trait-level and resource-level fields, while the WithResource* replacements populate only the resource level — a mechanical migration would drop trait-level fields from sync output. That migration needs its own coordinated change.

Intentional design notes for reviewers

  • cacheHolder is deliberate shared instance state — a documented deviation from the stateless-connector guideline, which assumes a remote API. Here the file is the source of truth and is re-read every sync, so a cold start is identical to a warm one. See the contract comment on cacheHolder in pkg/connector/connector.go.
  • ResourceSyncers() logging load failures at Warn and returning nil syncers is intentional: the method signature cannot return an error, and Validate() surfaces the real error to the SDK on every sync.
  • TestHotReload_* in pkg/connector/hot_reload_test.go enforce the hot-load contract; if a refactor makes them fail, the refactor reintroduces this bug.

Validation

  • go test ./... and go test -race ./pkg/connector/... green
  • golangci-lint v2.11.4 (CI-pinned): 0 issues (main: 7)
  • Capabilities/config JSON semantically identical to main
  • Main-vs-branch e2e output diff across all 12 example inputs: identical

🤖 Generated with Claude Code

The parsed-file cache was built exactly once at process startup inside
ResourceSyncers(), so long-lived services served stale data until
restart. Validate() — which the SDK runs at the start of every sync —
now rebuilds the cache from the current file contents and publishes it
atomically to a holder shared by all resource builders, restoring the
hot-load behavior that regressed in #40.

- Generation-stamped page tokens: a listing resumed across a cache swap
  restarts instead of silently skipping/duplicating rows; legacy bare
  numeric tokens are still honored so in-flight syncs survive upgrades
- Nil-cache guards in List/Entitlements/Grants replace potential panics
- An invalid file edit now fails the sync loudly while the last
  successfully loaded data keeps serving
- nolint:staticcheck on the deprecated trait options: they populate both
  trait- and resource-level fields, so migrating to the WithResource*
  replacements would drop trait-level fields from sync output
- Regression tests enforce the hot-load contract; README documents it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/connector.go Outdated
if err != nil {
return nil, fmt.Errorf("baton-file: failed to read input file: %w", err)
}
sum := sha256.Sum256(raw)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: loadValidatedCache unconditionally re-reads (twice — os.ReadFile then client.LoadFileData), re-validates, and rebuilds the full index set on every Validate(), including the health-check probes the cacheHolder comment calls out. Since sum is already computed before parsing, comparing it against the currently published cache's gen and returning that cache unchanged would skip the parse, index rebuild, and the ~2x-memory swap whenever the file has not actually changed.

Comment thread pkg/connector/connector.go Outdated
var syncers []connectorbuilder.ResourceSyncerV2
for _, rt := range cache.resourceTypes {
syncers = append(syncers, &resourceBuilder{cache: cache, resourceType: rt})
syncers = append(syncers, &resourceBuilder{cache: &fc.cache, resourceType: rt})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: resourceType is captured from the first cache and never refreshed, while the data cache is. If an edit changes an existing type's trait (e.g. team from group to role), the reloaded cache builds those resources with the new trait (buildResource switches on the new cache's resource type) while ResourceType() keeps reporting the old trait to the SDK — a trait mismatch that persists until restart. The README caveat only covers a brand-new resource type; worth widening it to any resource-type/trait change, or resolving resourceType from the live cache by id here.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: fix: hot-load file changes on every sync in long-running services

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 8ba14adeeefe.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (including vendor/modules.txt and the newly vendored zaptest/observer package) for security and correctness; no blocking issues found. Both prior findings on the implementation appear addressed at c865274: loadValidatedCache now retries torn reads by re-hashing after a failed parse before declaring the file invalid, and the restart-budget reset in paginate plus the README/docs/connector.mdx wording ("with no page served under an unchanged file in between", the new input file kept changing while being loaded failure) now match the implemented behavior with the liveness-vs-termination trade-off documented in place. The prior note that the retry path and the kept changing while being loaded error have no test coverage still stands — grep finds no test exercising loadValidatedCache's retry loop. The two suggestions below are new and concern hot-load coverage gaps, not the core fix.

Security Issues

None found. sha256 is used only as a content-change fingerprint, not for security; no secrets or PII reach logs or span/log fields.

Correctness Issues

None found. The generation-stamped token grammar round-trips (mintToken/parseOffset/parseRestarts agree on the 1..maxListingRestarts range), the load loop is bounded at 3 attempts on every path, refreshMu correctly serializes read→build→publish, and cacheHolder.load() is nil-safe for StaticCapabilitiesConnector builders.

Suggestions

  • pkg/connector/connector.go:257-273 — the schema-drift check compares only resource type IDs, so a trait edit on an already-registered type is never warned about, while buildResource (pkg/connector/resources.go:354) picks the trait from the live cache — emitting resources whose trait disagrees with the registered v2.ResourceType.
  • pkg/connector/connector.go:394-411 — hot-load coverage is asymmetric: an empty startup file registers all five standard TraitMap types, but a non-empty one registers only the types present, so a file with resources but no user rows never hot-loads its first user.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/connector.go`:
- Around line 257-273: the schema-drift check in Validate() only compares resource
  type IDs against fc.registeredTypes, so the second documented restart-required
  schema change — editing an existing type's trait in the file — is never detected.
  This is not inert: buildResource (pkg/connector/resources.go:354) selects which
  trait to attach from the live cache's resource type, so after such an edit the
  connector emits resources carrying the new trait while the SDK-registered
  v2.ResourceType for that ID still declares the old one. Change fc.registeredTypes
  from map[string]struct{} to map[string]*v2.ResourceType (populated in
  ResourceSyncers from rb.resourceType), and in Validate also compare each cached
  resource type's traits against the registered type's traits, folding any trait
  mismatch into the same level-triggered, logarithmically sampled warning (add a
  field naming the registered vs. file trait).
- Around line 394-411: hot-load coverage is asymmetric between the two registration
  branches. The empty-file branch registers all five standard TraitMap types, but
  the non-empty branch registers only the types the startup file happens to contain.
  Because newSyncCache only adds the "user" resource type when len(data.Users) > 0,
  a startup file containing resource rows but no user rows never registers the user
  type, so adding the first user row later only produces the drift warning and never
  syncs until restart — even though an empty startup file would have hot-loaded those
  same rows. Register the standard TraitMap types unconditionally in addition to the
  file-derived ones, deduping by resource type ID with the file-derived type taking
  precedence (so a file-declared trait still wins), and keep a single syncer per type
  ID so connectorbuilder.addResourceSyncers does not see duplicates. Update the
  hot-load docs in README.md and docs/connector.mdx if the restart-required set
  narrows as a result.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

matthewscobell and others added 2 commits August 13, 2026 18:37
Hot-load covers the file's data sections only; the set of resource types
and their traits is schema, fixed for the process lifetime because the
SDK registers syncers by type exactly once. Document that intent at the
builder-registration site and widen the README/contract wording from
"a new resource type" to any resource-type or trait change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add rationale comments at every site where this connector deviates from
standard patterns, for reviewers and future maintainers: the nil holder
on capabilities-only builders, the deprecated trait options kept to
preserve trait-level sync output, the raw-bytes fingerprint and its
benign double-read, the Debug/Warn log-level choices, and why hot-load
tests use real files instead of in-memory data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/resources.go Outdated
Comment thread pkg/connector/connector.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

…cuit

- Legacy bare-numeric page tokens now restart the listing when the cache
  is generation-stamped: their generation is unknowable and they only
  appear after a restart, exactly when the file may have changed.
  Generation-less caches (built directly in tests) still honor their own
  numeric mints — restarting those would loop forever.
- loadValidatedCache short-circuits when the content fingerprint matches
  the published cache generation: skips the redundant parse and rebuild
  on health-check probes and keeps no-op Validate calls from swapping
  the pointer under an in-flight sync.
- Document the cross-listing consistency limit in the cacheHolder
  contract: generation stamps make each listing consistent, not a whole
  sync; the short-circuit confines swaps to actual content changes.
- Add the hot-load note to the customer-facing docs/connector.mdx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/connector.go Outdated
Comment thread pkg/connector/connector.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- Serialize the read -> build -> publish sequence behind a mutex
  (extracted as FileConnector.refresh): the atomic pointer prevented
  data races but not lost updates — two concurrent Validate calls
  racing across two file edits could publish out of order. A CAS loop
  cannot fix this because generations are unordered content hashes;
  serializing makes publish order follow read order and prevents
  concurrent full cache builds.
- Correct the ResourceSyncers comment, which had the lifecycle
  backwards: the SDK calls it at construction BEFORE any Validate, so
  the construction-time load is the first load, not a fallback. Document
  the honest consequence: a file that is invalid at startup registers
  zero resource types, and recovery requires fixing the file AND
  restarting — hot-load only helps a connector that started
  successfully. Also documented in README and connector.mdx.
- Rebuild the hot-load tests on the real SDK entry point
  (connectorbuilder.NewConnector) and server RPCs so the SDK dictates
  the lifecycle order instead of the tests assuming it; the previous
  helper called Validate before ResourceSyncers — the reverse of
  production — leaving the actual startup path untested. Add
  TestHotReload_InvalidFileAtStartupRequiresRestart pinning the
  startup-failure behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread README.md Outdated
The in-code contract was corrected to reflect that ResourceSyncers()
performs the first file load at construction; the README paragraph
mirroring it still said "do not move the file load to construction
time", contradicting the code it points to. Reword to the actual
invariant: the construction-time load is the first load, never the
only one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/connector.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

A file with schema but no data rows previously registered zero resource
types, so every sync failed with FailedPrecondition for the process
lifetime — the same dead-end as an invalid file. Per product intent an
empty file is a valid state: ResourceSyncers now registers the standard
trait types (the same set StaticCapabilitiesConnector declares as the
connector's capabilities), so syncs succeed, emit nothing, and data
rows added later hot-load without a restart. Rows using custom type
IDs remain a schema change requiring restart; Validate() now warns
when a refresh publishes types that were not registered at startup —
previously that drift was completely silent.

The invalid-file-at-startup trap is unchanged and still pinned by its
test; new tests pin empty-start sync + hot-load and the custom-type
restart contract. README and connector.mdx updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/connector.go Outdated
Comment thread pkg/connector/connector.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- The schema-drift check now runs on EVERY Validate(), not just the one
  where the file's content hash changed: the condition persists until
  restart, so an edge-triggered warning meant one missable log line for
  a permanent problem. Logarithmic sampling (occurrences 1, 2, 4, 8,
  ... with total_occurrences) keeps probed deployments from flooding;
  the counter resets when drift resolves so a new episode warns
  immediately. TestHotReload_SchemaDriftCheckIsLevelTriggered pins the
  level-triggered contract.
- paginate() now logs a Warn when a generation mismatch (or a legacy
  pre-upgrade token) actually restarts a listing, making mid-sync cache
  swaps and the documented cross-phase consistency window observable in
  logs instead of inferable-only. The probe/sync-start distinction the
  alternative guard would need does not exist connector-side (same RPC,
  no sync-lifecycle hook) — recorded on the cacheHolder contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/resources.go Outdated
Comment thread pkg/connector/hot_reload_test.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- Page tokens now carry a restart count ("<gen>:<offset>[:<restarts>]"):
  a file rewritten faster than a listing can finish previously
  restarted that listing forever with no forward progress; after
  maxListingRestarts (3) the listing fails with a clear error naming
  the churning file, turning unbounded work into a retryable sync
  failure. The count is stateless — it rides the token — so it is
  scoped to one listing chain and needs no shared state.
- Assert the actual log emissions, not just internal state: a zap
  observer (go.uber.org/zap/zaptest/observer, vendored; same module
  version, go.mod untouched) now pins the drift Warn's logarithmic
  sampling (fires at occurrences 1, 2 and 4, not 3, with
  total_occurrences) and the two paginate restart Warns with their
  generation fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/resources.go
Comment thread README.md Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- The restart budget now resets when a listing completes a page under
  the current generation: the churn episode is over, so the count no
  longer accumulates for the lifetime of a listing chain, where a
  checkpointed token could have turned one later file change into a
  hard failure. The bound is therefore consecutive-restarts-without-
  progress; docs updated to say exactly that, and README/connector.mdx
  now document the bounded-restart failure mode operators can hit.
- Close a read-then-parse race in loadValidatedCache: the fingerprint
  was hashed from one read while the parser did its own second read, so
  an edit landing between them mislabeled the build — and a later
  revert to the hashed content would short-circuit onto the mislabeled
  cache indefinitely. The file is now re-hashed after parsing and the
  load retried (bounded) when the fingerprints disagree, so gen always
  describes the bytes the parser consumed.

Adversarially reviewed (token state machine enumerated, concurrency
stress-tested under -race through the real SDK server) and e2e-verified:
all 12 example inputs produce output identical to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread pkg/connector/resources.go
Comment thread pkg/connector/connector.go
Comment thread README.md Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

- A rewrite landing during the parse can tear the read and make a valid
  file transiently unparseable — at startup that needlessly forced a
  process restart. On parse failure the file is now re-hashed: changed
  (or momentarily unreadable) bytes mean a racing write and the load
  retries; unchanged bytes mean the file is genuinely invalid and the
  error is returned as before.
- Record the restart-bound liveness adjudication on maxListingRestarts:
  the bound is deliberately consecutive (progress resets it); decaying
  instead of resetting fails the same alternating-churn pattern it
  purports to fix, and a cumulative count reintroduces the checkpoint
  hard-failure. Documented so it is not "fixed" without solving that
  tension.
- Docs precision: the restart bound applies to consecutive restarts
  with no page under an unchanged file in between, and the new
  "kept changing while being loaded" failure is now documented in
  README and connector.mdx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@matthewscobell

Copy link
Copy Markdown
Contributor Author

Re the restart-budget decay suggestion: adjudicated as won't-fix. The alternating-churn pattern is real, but the proposed decay does not terminate under it either — mismatch +1 / progress −1 oscillates below any bound forever; any decay rate ≥ the churn rate loops. Guaranteed termination requires a cumulative budget, which reintroduces the checkpointed-token hard failure fixed earlier on this PR. We deliberately chose liveness: the exploiting rhythm is implausible against microsecond in-memory pages, and task-level timeouts bound a hung sync externally. The trade-off is recorded on the maxListingRestarts comment. The torn-read retry and docs-precision suggestions from the same review are addressed in the latest commit.

Comment thread pkg/connector/connector.go
Comment thread pkg/connector/connector.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

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.

2 participants