Skip to content

CXH-1905: ServiceNow incremental sync (watermark + delete capture) - #53

Open
cvkramer wants to merge 10 commits into
mainfrom
charleskramer/cxh-1905-servicenow-incremental-sync-watermark-delete-capture
Open

CXH-1905: ServiceNow incremental sync (watermark + delete capture)#53
cvkramer wants to merge 10 commits into
mainfrom
charleskramer/cxh-1905-servicenow-incremental-sync-watermark-delete-capture

Conversation

@cvkramer

Copy link
Copy Markdown

Implements CXH-1905 — opt-in incremental sync (--incremental, default off; full behavior unchanged when off).

Why connector-managed

baton-sdk v0.15.5 has no working connector-facing delta/merge, so a naive list-filter would permanently drop unchanged resources from each c1z. The connector keeps its own prior snapshot, fetches only rows changed since sys_updated_on, merges over the snapshot, and emits the full union — the c1z stays whole while the API only pays for changed rows.

What this adds

  • Watermark plumbing (sys_updated_on) + …UpdatedSince / GetAll…UpdatedSince client methods.
  • pkg/incremental state store: per-deployment JSON snapshot under --state-dir, per-stream watermarks, atomic temp-file+rename persist, version migration. Disabled/missing/corrupt ⇒ safe full pull.
  • Hard-delete capture via sys_audit_delete (tablename-scoped prune, shared delete-watermark, graceful degradation — never fails the sync).
  • Correctness fixes: per-run watermark freeze, unique-temp-file persist, cap watermark at now().
  • appendUpdatedSince appends ^ORDERBYsys_updated_on when a watermark is set, so offset pagination walks forward deterministically (full-pull path unchanged).

Validation (live, dev289997)

  • Baseline full sync, cold incremental, and warm incremental all = 638 users / 55 groups / 733 roles / 4928 grants — warm emitting the full set confirms the watermark-freeze fix.
  • Additions captured (+20-grant cascade from one membership add).
  • Deletion-capture prunes audited deletes (−1, delete-watermark advanced). Non-audited cascade deletions linger until the full-sync backstop — the documented best-effort behavior, confirmed on a real instance.
  • ORDERBY pagination verified complete/dup-free against the live Table API. go build/vet/test green; golangci-lint: 0 new findings vs main.

Merge-order note

Shares several pkg/servicenow symbols (GetUsersUpdatedSince, AuditDeleteRecord/AuditDeleteResponse, appendUpdatedSince, AuditedTables, access-table consts) with the event-feed branch (CXH-1906). Whichever of the two merges second must rebase and drop the duplicates.

🤖 Generated with Claude Code

c1-charleskramer and others added 9 commits June 22, 2026 13:30
…al state store

Add the connector-side primitives for incremental sync against ServiceNow's
sys_updated_on column:

- model.go: carry sys_updated_on on User/Role/Group and the membership rows
  (GroupMember, UserToRole, GroupToRole).
- request.go: include sys_updated_on in the fields lists; add appendUpdatedSince
  to AND a "sys_updated_on>=<ts>" clause onto a query (empty ts = no-op).
- client.go: add *UpdatedSince list variants (originals delegate with "" for a
  full pull, preserving behavior) plus drainAll + GetAll*UpdatedSince helpers
  that page through an entire delta in one call.
- pkg/incremental: new connector-managed state store. baton-sdk v0.15.5 has no
  connector-facing delta/merge (per-sync session store; dormant etag replay), so
  a bare list-filter would drop unchanged rows from the c1z. State keeps a
  per-deployment JSON snapshot + per-stream watermarks, merges deltas by sys_id,
  persists inline (the SDK does not forward Close on in-process syncs), and emits
  the full union so the c1z stays complete. Strictly opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ental flag

Thread the incremental.State through the connector and the user/role/group
syncers. When --incremental is set, each List (and group/role Grants) drains
only rows changed since the per-stream watermark, merges them over the cached
snapshot, and emits the full union as a single page; on error it marks the run
failed so no watermark is advanced past unsynced rows. The non-incremental path
(default) is unchanged.

- connector.go: ServiceNow holds *incremental.State; New takes
  incrementalEnabled + stateDir; Close best-effort saves state.
- user/group/role.go: state.Enabled() incremental branches; shared
  resource/grant construction helpers.
- config.go (+ regenerated conf.gen.go): --incremental (default off) and
  --state-dir (CLI-only); main.go passes them through.
- INCREMENTAL_SYNC_NOTES.md: full design, SDK-constraint research, live-test
  results, limitations (deletions, TZ), and the on-call-branch follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ental sync

Close the deletion gap in incremental sync: sys_updated_on polling detects
inserts/updates but not hard deletes, leaving stale rows (and stale grants) in
the cached snapshot. Add a sys_audit_delete reconciliation pass that prunes
deleted sys_ids out of the snapshot once per run, before any merged union is
built.

- model.go: AuditDeleteRecord type, audited table-name constants, AuditedTables.
- client.go: GetDeletedSince / GetAllDeletedSince drainers over sys_audit_delete.
- incremental/state.go: DeleteWatermark on the snapshot (separate from update
  watermarks); Deleter interface injected at Load; Reconcile(ctx) runs once per
  run (sync.Once), prunes resources by sys_id and join rows out of the nested
  GroupMembers/UserRoles/GroupRoles maps (tablename-scoped so a sys_id collision
  can't prune the wrong record), advances + persists the delete watermark.
  Graceful degradation: an audit-table error is logged and swallowed (full-sync
  backstop reconciles), never failing the sync.
- connector.go: pass the client as Deleter to Load.
- user/group/role.go: call state.Reconcile(ctx) at the top of each incremental
  branch (idempotent via sync.Once).
- state_test.go: prune-resource, prune-join-row, tablename-scoping, reconcile +
  watermark advance + once-per-run, graceful-degradation, disabled-no-op tests.
- stateVersion bumped 1->2 (forces a clean full pull on upgrade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… deletion capture

Two correctness fixes surfaced while live-testing deletion capture on dev289997:

1. Watermark freeze within a run. The per-group/per-role grant fetches share one
   stream watermark that each Merge* advances inline. Across the many parallel
   fetches in a single sync, a far-future sys_updated_on (the instance has demo
   memberships dated 2031) advanced the stored watermark mid-sync, so every
   group/role processed afterward fetched ZERO rows -- silently dropping most
   grants on every warm sync (observed 71 grants vs ~4900 correct). State.Watermark
   now freezes the value at first read per run (readWatermarks); the stored
   watermark still advances for the NEXT run.

2. Atomic-persist race. persist() used a fixed '<path>.tmp' name; when the sdk
   drives the connector across multiple goroutines/instances sharing one state
   path, two writers collided and one rename hit a temp the other had already
   renamed away (ENOENT), failing the sync. persist now uses a unique
   os.CreateTemp(dir, base+'.tmp-*') per call.

Added TestWatermarkFrozenWithinRun. Documented deletion capture, the three
deletion caveats, the periodic-full-sync backstop, both fixes, the live deletion
test result, and the cmn_rota_member on-call follow-up in INCREMENTAL_SYNC_NOTES.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tall detection

A future-dated sys_updated_on (ServiceNow demo-data date shifting — e.g. the
ITSM App-Dev sample group dated 2031 — clock skew, or imports that preserve
source timestamps) was seeding the group_members/groups watermarks to 2031, so
real 2026 changes fell below the watermark and were missed by warm syncs until a
full sync. advance() now ignores ts in the future (and empty ts); the delete
watermark gets the same cap. Future-dated rows are still fetched/merged each run
(idempotent). Verified on dev289997: group_members watermark now seeds to the
real 2026 max instead of 2031.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cut essay-level comment blocks (state.go was 30% comment lines) down toward the
repo's sparse ~2-4% norm, keeping concise godoc on exported symbols and the
load-bearing why-notes (watermark cap at now(); snapshot+merge because the SDK
has no native delta; per-stream watermarks; sys_audit_delete deletion reconcile).
No code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nilerr (intentional full-pull fallback on corrupt snapshot -> //nolint with reason),
revive line-length (split long state-dir description), and goconst (use the package
field-name consts fieldSysID/fieldName/UpdatedSinceField in request.go slices).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Working/design notes captured in CXH-1905; kept out of the connector tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t map doc

- appendUpdatedSince now appends ^ORDERBYsys_updated_on when a watermark is
  set, so every incremental …UpdatedSince drain paginates in a stable
  ascending order. Without it the Table API can return rows in an unstable
  order across offset pages, silently skipping rows. Full-pull path (empty
  ts) is unchanged. (Same shared helper as the event-feed branch.)
- Snapshot.UserRoles/GroupRoles doc comments said 'user/group sys_id' for the
  outer key, but both are keyed by role sys_id (MergeUserRoles/MergeGroupRoles
  take roleID). Corrected to avoid misleading maintainers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cvkramer
cvkramer requested a review from a team June 22, 2026 21:15
@linear-code

linear-code Bot commented Jun 22, 2026

Copy link
Copy Markdown

CXH-1905

Comment thread pkg/incremental/state.go

// persist marshals and atomically writes the snapshot. Caller must hold s.mu.
// A no-op once the run has been marked failed.
func (s *State) persist() error {

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: persist() serializes and atomically rewrites the entire snapshot (all users/groups/roles + every join map) on each Merge* call. Since Grants runs per group and per role, a sync triggers roughly G + 2R full-snapshot rewrites — O(N) JSON marshals of the whole dataset per run, which can be costly on large deployments. Consider persisting once at end of run (you already added Save()/Close()) or debouncing, while keeping MarkFailed semantics. (confidence: medium, non-blocking)

Comment thread pkg/incremental/state.go Outdated
// a future-dated sys_updated_on (clock skew, demo data) would otherwise push the
// watermark beyond real changes and stall detection until a full sync. Lexical
// compare is valid for the fixed-width UTC string. Caller must hold s.mu.
func (s *State) advance(stream Stream, ts string) {

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: the next-run watermark advances to the global max sys_updated_on across a stream, but join streams (e.g. group_members) share one watermark across all groups/roles drained sequentially. If group A is drained early and group B later yields a higher max, then a change to group A occurring after A was drained but with a timestamp below B's max is skipped on the next run (>= watermark excludes it). The frozen read-watermark prevents intra-run skips, but not this cross-entity end-watermark gap. It's reconciled by the periodic full sync, so this is by-design; worth documenting that reliance, or using sync-start time as the next watermark. (confidence: low, non-blocking)

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXH-1905: ServiceNow incremental sync (watermark + delete capture)

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 4465bf563621.
Review mode: full
View review run: https://github.com/ConductorOne/baton-servicenow/actions/runs/28276874546

Review Summary

Full PR diff scanned for security and correctness. This change adds opt-in connector-managed incremental sync: a per-deployment on-disk snapshot plus per-stream sys_updated_on watermarks, delta-merge by sys_id, and hard-delete reconciliation via sys_audit_delete. The core logic is sound and well covered by tests (watermark freezing within a run, run-start advance, prune scoping by table, graceful audit degradation). The prior watermark concern, a shared per-stream watermark advancing past rows of entities drained late, is now addressed by storing run-start instead of the max row timestamp (commit 9a89b8c). No security issues and no blocking correctness issues found: state files are written via os.CreateTemp (0600) plus atomic rename, the watermark timestamp is URL-encoded, and the deployment name is sanitized for the filename path. Remaining items are non-blocking suggestions.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/incremental/state.go:342 persist() rewrites the full snapshot on every Merge* (approx G + 2R full serializations per sync); consider debouncing or persisting once via Save()/Close().
  • pkg/incremental/state.go:245 first incremental run drains the entire sys_audit_delete history (empty DeleteWatermark) while the snapshot is empty, so nothing is pruned; seed the delete watermark on first seed to avoid the unbounded scan.
  • docs/connector.mdx new incremental and state-dir config fields, plus the new sys_audit_delete read dependency (needed for deletion capture; degrades gracefully if absent), are not reflected in the docs.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/incremental/state.go:
- Around line 342 (persist): persist() re-marshals and rewrites the entire snapshot on every Merge* call, roughly G + 2R full-file writes per sync. Reduce write amplification (debounce, mark-dirty plus flush once via Save()/Close(), or persist only the changed stream), while keeping the failure-safety guarantee that watermarks never advance past unsynced rows on a partial run.
- Around line 245 (Reconcile): on the first incremental run DeleteWatermark is empty, so GetAllDeletedSince drains the entire sys_audit_delete history even though the freshly-seeded snapshot is empty and nothing can be pruned. When the snapshot was just seeded, set DeleteWatermark to runStart (or skip reconcile) to avoid an unbounded audit scan with no pruning benefit.

In docs/connector.mdx:
- Document the new config fields incremental (bool) and state-dir (string), and add the sys_audit_delete read dependency to the credential/permission requirements (note it degrades gracefully and is only used for deletion capture during incremental sync).

@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.

Per-stream watermarks (especially the join streams group_members/user_roles/
group_roles, which share one watermark across many groups/roles drained
sequentially) advanced to the max sys_updated_on observed. That could skip a row
changed mid-sync on an entity drained early, since its timestamp can fall below
a later entity's max, and the next run reads >= that max.

Advance each synced stream to the run's start time instead. Reads always begin
at/after run-start with the frozen lower bound, so everything before run-start
was captured; anything at/after is re-fetched next run (idempotent upsert by
sys_id). This also subsumes the prior future-dated-row cap (run-start <= now).

The run-start clock is injectable (nowFn) for deterministic tests. Addresses PR
review #53.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread pkg/incremental/state.go

// persist marshals and atomically writes the snapshot. Caller must hold s.mu.
// A no-op once the run has been marked failed.
func (s *State) persist() error {

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: persist() re-marshals and atomically rewrites the entire snapshot on every Merge* call. With one MergeUsers/MergeRoles/MergeGroups plus per-group/per-role member merges, a sync does roughly G + 2R full-file serializations (the whole users/groups/roles maps each time). On large deployments this is significant disk/CPU churn. Since each Merge* already advances only its own stream, consider debouncing or relying on the final Save()/Close() flush instead of persisting on every merge. (low confidence — correctness is fine; this is an efficiency concern.)

Comment thread pkg/incremental/state.go
l := ctxzap.Extract(ctx)

s.mu.Lock()
since := s.snapshot.DeleteWatermark

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: On the very first incremental run the stored DeleteWatermark is empty, so GetAllDeletedSince is called with createdSince="" and drains the entire sys_audit_delete history (potentially millions of rows on a busy instance) — yet the snapshot is empty on that run, so nothing is pruned. Consider seeding DeleteWatermark to runStart (or skipping reconcile) when the snapshot was freshly seeded, so the first run doesn't pay an unbounded audit scan for no benefit. (low confidence — efficiency, not correctness.)

@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