Skip to content

perf: scale Full Synchronisation, fix OpenLDAP membership batching#1094

Merged
JayVDZ merged 36 commits into
mainfrom
feature/openldap-scenario8-membership-batching
Jul 21, 2026
Merged

perf: scale Full Synchronisation, fix OpenLDAP membership batching#1094
JayVDZ merged 36 commits into
mainfrom
feature/openldap-scenario8-membership-batching

Conversation

@JayVDZ

@JayVDZ JayVDZ commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • OpenLDAP Scenario 8: batch group membership modifications into chunked LDAP modify requests instead of one per member, fixing a quadratic tail on large-group export; strip accesslog from committed snapshot images
  • Full Synchronisation scale-out: keyset-cursor CSO paging, batched import reference lookups, hash-based multi-valued drift comparison, chunked whole-system Pending Export preload, batched change-record reference fixup, dedup of reference-recall RPEIs with correct Pending Export linkage, incremental-counter Run Profile stats, paced worker main loop
  • PowerShell cmdlets: send enum request values as string names (not numeric ordinals) for metaverse and general requests; fix cmdlets to match their documented behaviour
  • Throttle and set-base the API key usage-stamp write in JIM.Web
  • Merges in origin/main, including the Dependabot batch that bumped System.Security.Cryptography.Xml (and related Microsoft.* packages) to 10.0.10, resolving an NU1903 high-severity audit failure that was blocking builds

Test plan

  • dotnet build JIM.sln clean, 0 warnings/errors
  • dotnet test JIM.sln clean: 3,780 tests, 0 failed (111 skipped)

🤖 Generated with Claude Code

JayVDZ and others added 30 commits July 17, 2026 19:03
…r chunk

The Scale500k25kGroups snapshot build stalled for hours partway through Step 4
(group membership assignment), stuck at ~16000/24997 groups with slapd pinned at
95% CPU. Root cause: the populator emitted one LDAP modify per member. back-mdb
stores each group as a single entry, so every "add: member" rewrites the whole
entry; building the 315k-member AllStaff group one member at a time is O(N^2).

Measured against the real base image: adding 20000 members took 60.3s per-member
vs 1.83s as multi-valued modifies (identical resulting membership). The gap
widens with N, so the 315k-member group extrapolates to ~4 hours and never
completed within the run.

Emit a single multi-valued modify per 5000-member chunk instead: all values are
applied in one entry rewrite (~O(N)), each request stays under OpenLDAP's
authenticated socket-buffer cap, and the directory state is byte-identical. This
also cuts the number of accesslog entries the build generates by orders of
magnitude (previously enough to fill the 128GB accesslog and log MDB_MAP_FULL on
every write).

Editing the populator changes the snapshot content hash, so cached Scenario 8
snapshots rebuild automatically on the next run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Snapshot images were bloated to ~138GB (e.g. jim-openldap:s8-scale100k5kgroups)
because docker commit baked in the live accesslog. /bitnami/openldap is not a
Docker volume (the base image declares none), so the whole live tree is committed;
the previous code dropped the accesslog only from the .provisioned restore copy,
never the live one, leaving the full accesslog in the image layer. The
Scale500k25kGroups build fills the entire 128GB accesslog cap.

Remove the live accesslog MDB before the .provisioned backup and commit. slapd is
idle post-populate and holds the file open, so the unlink is safe and docker commit
(which walks the filesystem tree) excludes the now-unlinked file. start-openldap.sh
already recreates a fresh accesslog on every boot, so nothing is lost. Doing it
before the backup also removes the now-redundant .provisioned accesslog cleanup and
avoids copying the accesslog at all.

Validated against the real base image: an accesslog-inflated build committed at
276MB unchanged vs 210MB with the fix (the accesslog is excluded); the fixed image
boots, restores its data, and its fresh accesslog correctly logs new writes
(verified via cn=accesslog reqType entries), so delta-import change capture is
unaffected.

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

PR #1060 configured the API's JsonStringEnumConverter with allowIntegerValues:false,
so every enum-typed request DTO property (and query-string enum) must be a string
name; numeric ordinals now return 400. Six cmdlets still mapped their ValidateSet
string parameters to integer enum ordinals before sending them, so they broke
against the API:

- New-JIMSyncRule (direction) - the failure that surfaced this, in Setup-Scenario8
- New-JIMRunProfile (runType)
- New-JIMSchedule (triggerType, patternType, intervalUnit)
- Set-JIMSchedule (triggerType, patternType, intervalUnit)
- Switch-JIMMatchingMode (mode)
- Get-JIMScheduleExecution (status, in the query string)

Each parameter's ValidateSet already equals the exact enum member names, so the
int-mapping switches were redundant as well as wrong; send the validated string
directly. #1060's note claimed the PowerShell module already sent strings; it did
not, because the module shipped no request-body assertion for enum fields and no
integration run had exercised these cmdlets since #1060 merged.

Adds EnumSerialisation.Tests.ps1 pinning all six cmdlets to the string-name contract
(red before the fix, green after). Full JIM.PowerShell Pester suite: 1064 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the enum-as-string fix. Four more cmdlets sent numeric enum ordinals
via a hashtable map instead of a switch, so the first sweep (which only matched the
switch form) missed them. They broke against the API's allowIntegerValues:false
converter (PR #1060), surfacing at Scenario 8 Step 13 (Set-JIMMetaverseObjectType
-DeletionRule):

- New-JIMMetaverseObjectType / Set-JIMMetaverseObjectType (deletionRule)
- New-JIMMetaverseAttribute (type, attributePlurality)
- Set-JIMMetaverseAttribute (type + attributePlurality on the schema PATCH,
  renderingHint on the metadata PATCH)

Each parameter's ValidateSet already equals the exact enum member names, so send the
validated string directly. The one exception is -Type, whose ValidateSet exposes
'Integer' as a friendly alias for the AttributeDataType member 'Number'; that alias
is normalised to 'Number' before sending (sending 'Integer' would 400 as an
undefined enum value). Set-JIMMetaverseAttribute back-fills an unsupplied schema
value from the current attribute, which the API already returns as a name, so those
flow through unchanged.

A repository-wide sweep for all int-producing forms (switch{int}, @{name=int} maps
inline and multi-line, $map[...] into bodies, bare int literals, [int] casts)
confirms these are the last of them; the remaining [int] casts are all entity IDs.

Extends EnumSerialisation.Tests.ps1 to all six metaverse cases (incl. the
Integer->Number alias) and updates the one existing Metaverse test that asserted the
old integer schema body. Full JIM.PowerShell Pester suite: 1070 passing. Live-API
verified: deletionRule=1 -> 400 (the Step 13 error), "WhenLast..." -> 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scale500k25kGroups exposed a quadratic slowdown exporting huge groups:
slapd duplicate-checks each added member against every existing value
with a linear scan and rewrites the whole entry per modify, so at batch
size 100 a 500K-member group needs 5,000 round trips whose cost grows
with the entry (measured 0.02s -> 1.3s per modify at 350K members).

- Raise the LDAP Connector's default Modify Batch Size from 100 to 1000
  (10x fewer round trips; existing Connected Systems keep their stored
  value, new ones pick up the new default)
- Enable olcSortVals for 'member' in the OpenLDAP test image so the
  duplicate check is a binary search; applied at image build only, as
  sortvals must not be reconciled onto snapshot data stored unsorted
  (the init script is part of the snapshot content hash, so existing
  snapshots rebuild automatically)
- Scale the OpenLDAP container memory limit per template (2G default,
  up to 12G at Scale1m) so the MDB page-cache working set fits at scale
- Document sortvals tuning in the public LDAP Connector docs with a
  link to the OpenLDAP tuning guide, and record the accesslog logold
  write-amplification constraint in test/CLAUDE.md (JIM's delta import
  needs reqOld to type deletes, so logold cannot be dropped yet)

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

The Scale500k25kGroups run failed at the confirming import:
FixupCrossBatchChangeRecordReferenceIdsAsync inherited 6.5M unresolved
change record reference values (written by the sync and export stages
with ReferenceValueId nulled for cross-batch FK safety) and tried to
resolve them in a single UPDATE, which blew the 300s bulk command
timeout. Measured: the matching join scan takes ~17s; the unbounded
write side is what dies.

- Rewrite the fixup as two phases: one scan materialises every
  resolution into a session-local temp table, then bounded UPDATE
  batches (default 250K rows) apply them, each statement well inside
  the timeout. Partial progress is durable and the operation is
  idempotent, so an interrupted run resumes with less to do.
- Resolve at export completion too, where most of the backlog is
  created, so the next import no longer inherits a multi-million-row
  debt. The import-end call remains as the safety net.
- Cover the rewritten SQL with real-PostgreSQL regression tests
  (multi-batch resolution, case-insensitive DN matching, Connected
  System scoping, non-reference exclusion, unresolvable-row
  termination, idempotency), opt-in via JIM_TEST_RESET_*.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment timeout

The Scale500k25kGroups re-run (2026-07-19) got past the change record
fixup (previous commit, proven at 6.16M rows in production) and failed
one step later: GetPendingExportsLightweightByConnectedSystemIdAsync
loaded 525K Pending Exports joined to 9.8M attribute value changes in a
single collection-Include EF statement, which the server-side
statement_timeout (5 min) killed during the confirming import's
reconciliation.

- Load in keyset-paginated chunks (default 50K Pending Exports per
  page; measured 0.66s per chunk at full scale): a page of headers,
  then value changes by PendingExportId, with Attribute navigations
  stitched from a shared lookup instead of a per-row Include.
- AsNoTracking throughout: the background pre-load context was already
  disposed immediately after the query (entities detached), and the
  duplicate self-heal deletes via raw SQL, so tracking bought nothing.
- Real-PostgreSQL regression tests: completeness across chunk
  boundaries, Attribute stitching, system and null-CSO scoping, and
  duplicate self-heal across chunks (unique index dropped to simulate
  the legacy data the self-heal exists to repair).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Activity "Operations" tab (ActivityDetail) pegged JIM.Web at 100% CPU and took
many seconds to load for activities with tens of thousands of RPEIs, even though it
is server-paginated to 100 rows. GetActivityRunProfileExecutionItemHeadersAsync
Include()d each RPEI's ConnectedSystemObject plus its entire (multi-valued)
AttributeValues collection, materialised full tracked entities for the page, then
projected in memory, so a page landing on a few large group CSOs pulled tens of
thousands of member attribute-value rows into memory to render 100 grid rows.

Rewrite as an AsNoTracking SQL projection: resolve the live display name / external
id / type via correlated scalar subqueries (run only for the page's rows), falling
back to the RPEI snapshot columns. External-id value columns are projected
individually; a single multi-column subquery projection makes EF Core emit a
ROW_NUMBER() window over the WHOLE AttributeValues table (millions of rows) instead
of a correlated subquery. Also hoist the outcome-type filter's enum ToString() out
of the predicate so it translates to SQL instead of throwing.

Measured against the real Scale500k25kGroups dev database on a 26,824-RPEI activity:
default page 1.3s cold (was many seconds / 100% CPU and effectively unusable); deep
paging, display-name sort and outcome-type filtering all execute. Behaviour
preserved (live values with snapshot fallback); existing tests green. No index
needed: the ORDER BY Id top-N sort is ~8ms; the cost was purely the per-row graph
materialisation.

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

Reference recall stages a membership-removal Pending Export for a referencing
group on every synchronisation page that deletes one of its members, and the
delete-then-create persistence coalesces those into a single Pending Export.
Emitting one RPEI per page-flush reported that single export as many RPEIs
(21,824 for 5,421 real Pending Exports on Scale500k25kGroups, and >100x for hub
groups referenced by leavers on hundreds of pages), inflating TotalPendingExports
and bloating the operations table with rows pointing at superseded Pending Exports.

- Defer recall RPEI emission to end of run, keyed by referencing CSO (last write
  wins: the final flush's Pending Export already carries every prior page's
  removals), so exactly one RPEI is emitted per group, carrying the coalesced
  Pending Export and the full removal count.
- Persist PendingExportId in both raw RPEI insert paths (single-connection INSERT
  and parallel COPY); it was silently dropped, so every recall RPEI landed with a
  NULL link and the operations-tab drill-down could not load the Pending Export.
- Populate ExternalIdSnapshot/ObjectTypeSnapshot on recall RPEIs via one
  end-of-run Summary-tier lookup (correlated scalar subqueries, no member-value
  materialisation), so each stays self-describing if its group CSO is later deleted.

Adds dedup unit tests and RequiresPostgres regression tests for the raw-insert
column mapping and the snapshot projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The largest end-to-end validation is Scenario8 CrossDomainEntitlementSync at
Scale500k25kGroups: 500,000 users and 24,997 groups synchronised cross-domain
across two directories, with the largest group at 495,000 members. Update the
README, docs landing page, and roadmap from "100K+ object scale", and correct
the LDAP connector tuning note that incorrectly claimed testing "up to 1 million
users" (JIM has not been run at that size).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the open-ended "100,000+ objects" tier into 100k-250k and 250k-500k rows,
and add a callout with the measured peak from the largest validated scenario
(~55 GB total host RAM for the Scenario8 cross-domain run: two ~500k-object
directories with groups up to 495,000 members). Notes that large group
memberships, not raw object count, are the dominant memory driver, so the same
object count with only small groups needs considerably less.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration runner composes docker-compose.override.yml, whose dev
database profile (256MB shared_buffers, 1GB max_wal_size) previously
applied at every template size. At Scale500k25kGroups this triggered
WAL-pressure checkpoints every ~12 seconds ("checkpoints are occurring
too frequently" logged 1,488 times over the run), each stalling bulk
COPY streams for up to 78s.

The override's database command and shm_size are now parameterised via
JIM_DB_* environment variables (unchanged dev defaults when unset), and
Run-IntegrationTests.ps1 assigns a profile per Scale template before
compose up, mirroring the OPENLDAP_PRIMARY_MEMORY scaling. Scale
profiles also space checkpoints (15min) and enable lz4 WAL compression,
which shrinks the full-page writes that dominate bulk-load WAL. The
override now sets effective_io_concurrency=1000, matching the NVMe
assumption in docker-compose.yml (it previously fell back to
PostgreSQL's spinning-disk default of 16).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ValuesEqual compared expected vs actual multi-valued sets by scanning
the whole actual set linearly for every expected value: O(n^2) in
membership size. At Scale500k25kGroups this cost ~36s of CPU per large
group and 2,073s (66%) of the 52-minute confirming Full
Synchronisation, measured via per-CSO diagnostic spans.

A HashSet.SetEquals fast path now settles the overwhelmingly common
same-typed no-drift case in O(n); the pairwise scan is retained as a
fallback for the cross-type semantics hash equality cannot see (Guid vs
its string representation, byte array content), itself filtered by an
O(1) hash lookup so only genuinely hash-missing values pay for a scan.

The regression test compares two identical 30,000-member reference sets
under a generous 2s ceiling (9.9s before the fix, milliseconds after);
a second test locks in the Guid-to-Text fallback semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e list check

Two costs in the confirming Full Import's processing phase (2,537s vs
366s for the equivalent create-path import at Scale500k25kGroups):

- GetReferenceExternalIdsAsync ran once per existing imported object:
  535,425 individual round trips measured via pg_stat_statements. A new
  batched GetReferenceExternalIdsForCsosAsync fetches a whole hydration
  page's lookups in one = ANY query; every requested ID is present in
  the result (empty when the CSO has no resolved references), so only
  CSOs matched outside the hydrated page fall back to the single-CSO
  query. Proven against real PostgreSQL for per-owner grouping,
  secondary-over-primary preference and single-CSO parity.

- The update-list duplicate guard scanned the growing list with
  List.Any per object: ~1.4x10^11 Guid comparisons across 525K objects.
  A companion HashSet now gives O(1) membership, maintained at every
  site that appends to the update list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetConnectedSystemObjectsAsync paged with OFFSET, which re-scans and
discards all rows before the requested page, so per-page cost grows
linearly with page number: 977s across 1,050 page loads (~200ms early
pages degrading to ~1.5s late ones) during the Scale500k25kGroups
confirming Full Synchronisation.

The full sync loop now chains a keyset cursor (afterId, introduced on
the repository in the previous commit) starting at Guid.Empty, keeping
every page O(pageSize). The cursor is always the last row of the
previous page exactly as the database returned it: PostgreSQL orders
uuids bytewise, which differs from .NET's Guid.CompareTo, so each
engine's ordering must be paired with its own comparison; a client-side
max would skip rows. Offset behaviour is unchanged for random-access
callers (UI paging).

Real-PostgreSQL tests prove completeness and non-overlap of the cursor
chain in both the watermark and standard loading branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The worker main loop only delayed in its idle branch; while any task
was processing it spun heartbeat UPDATE + cancellation SELECT pairs as
fast as the two round trips allowed. Measured at Scale500k25kGroups:
6.7M heartbeat updates, 6.7M cancellation checks and 16.4M
connection-pool resets over one run (~380 queries/second of constant
background load against the same database the sync was writing to),
plus ~294M referential-integrity trigger subqueries server-side.

The busy branch now paces at 2s, matching the idle cadence. Stale-task
recovery tolerates far coarser heartbeats, and up to 2s of extra
cancellation latency is acceptable. Validated at runtime: 9 heartbeat
updates over an ~18s task window (0.5/s) on the rebuilt worker.

Also records the changelog entries for the synchronisation performance
fixes in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the last outstanding item on #952: pre-upgrade backups must
capture the encryption key set alongside the database, or a rollback
to that backup leaves every stored secret undecryptable.

The Upgrading page was a stub, so a bare backup note would not have
been read. Replaces it with a full how-to: pre-upgrade checklist,
connected and air-gapped procedures, what happens during the upgrade
window (the worker applies migrations; web and scheduler gate on
readiness), verification, PowerShell module, and rollback.

Also corrects Verify Startup in the deployment guide, which told
readers to look for "Database migrations applied" / "Database is up
to date" in the worker logs. Neither string is emitted anywhere in
the codebase; migration timing is logged at Verbose only. Points at
the readiness endpoint and the web service's actual Information-level
wait message instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Schedule quiesce/restore steps to the upgrade procedure. A
Schedule firing mid-upgrade starts a Run Profile execution that the
service stop then interrupts, so Schedules come off first and the
Activity drain follows.

Both halves record the enabled set rather than blanket-toggling:
Schedules deliberately disabled before the upgrade must stay disabled,
which a naive "enable everything afterwards" would silently undo.
Leaving Schedules off is itself a silent failure mode (JIM looks
healthy, nothing synchronises), so re-enabling is a checked step.

Also fixes a PowerShell doc example selecting a non-existent property:
the schedule DTO exposes IsEnabled, not Enabled, so `Select-Object
Name, Enabled` renders an empty column. Found while writing the
Where-Object filter for the disable snippet, where the same mistake
would have made the command a silent no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sweeps the customer-facing upgrade and deployment prose from "database
migrations" to "database upgrades". Entity Framework Core is an
implementation detail; an administrator planning a maintenance window
does not need to know which ORM applies the schema change, only that
it happens automatically and gates readiness.

The rollback section keeps the term, because it invokes `dotnet ef
migrations list` directly and the argument is a migration name.
configuration.md's "provider migration" is an unrelated sense and is
left alone.

Also refreshes the Upgrading card on the administration index, which
still advertised the stub's "migration notes".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rollback procedure told customers to run `dotnet ef migrations
list` and `dotnet ef database update` inside the jim.web container.
That is developer tooling in a customer-facing runbook, and it could
never have worked: the final jim.web image is built on the ASP.NET
runtime base, which ships neither the .NET SDK nor the dotnet-ef tool,
so both commands fail on a production deployment.

Replaces it with the procedure customers actually have available:
stop, restore the pre-upgrade backup (database and encryption keys as
a matched pair), pin JIM_VERSION back, start, verify, re-enable
Schedules. Adds a warning against running an older JIM against an
upgraded schema, and says plainly not to roll back at all without a
usable backup.

Also drops the EF Core reference from the factory-reset cmdlet notes,
adds an explicit {#restoring} anchor to the emoji-headed Restoring
section so the new cross-link resolves, and replaces a "contact
support" instruction that named a channel the docs never document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`docker compose exec jim.web dotnet ef ...` appeared in four live docs
as a rollback path, an air-gapped troubleshooting fallback, and a
"first run" step. It cannot work in any of them: every service image is
built on the ASP.NET runtime base, which carries neither the .NET SDK
nor the dotnet-ef tool, and the dev compose override does not swap in
an SDK stage either. It is also unnecessary, since the worker applies
pending migrations at startup and gates the other services on
readiness.

RELEASE_PROCESS.md now defers to the customer-facing rollback and
restore procedures rather than duplicating them, and states the
release-time implication: restoring a backup is the only production
rollback, so migrations should stay additive enough that a
fix-forward release is always available.

Backup guidance in the same file said "back up your database"; it now
says database and encryption keys as a matched pair, matching #952.

Also removes the same phantom worker log strings from
RELEASE_PROCESS.md that were corrected in deployment.md earlier on
this branch, and adds an explicit {#rolling-back} anchor so the new
cross-references resolve past the emoji heading.

plans/done/RELEASE_PROCESS_HARDENING.md keeps its copies; it is a
point-in-time record and exempt from retro-editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audited every example and Output table in docs/powershell/ against the
DTOs the cmdlets actually return. Eight of twenty files referenced
properties that do not exist. Because PowerShell resolves an unknown
property to $null rather than erroring, these fail silently.

Most severe, certificates.md's "Remove all disabled certificates":

    Get-JIMCertificate | Where-Object { -not $_.Enabled } | Remove-JIMCertificate -Force

TrustedCertificateHeader exposes IsEnabled, so $_.Enabled was $null for
every row, -not $null was true for every row, and -Force suppressed the
confirmation prompt. Copy-pasting that example deleted the entire
trusted certificate store without prompting.

The rest render empty columns or match nothing: activities.md filtered
execution items on a Status property they do not have (and on an
ActivityStatus value, "Failed", that is not in the enum; it is
FailedWithError); run-profiles.md filtered on Type rather than RunType,
so "start all full imports" started none; connected-systems.md filtered
connector definitions on a Capabilities collection that exists in no
shape; index.md demonstrated the Attributes dictionary on the by-Id
result, which returns AttributeValues instead.

Left alone deliberately: synchronisation-rules.md's $_.Enabled is
correct, as SyncRuleHeader really does name it Enabled. A blanket
Enabled -> IsEnabled sweep would have broken it.

Also fixes the two cmdlets whose comment-based help carried the same
bad example, so Get-Help stops teaching it.

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

The Activity detail page re-aggregated every Run Profile Execution Item
and Sync Outcome on each 3-second progress poll (four GROUP BYs over
~525k RPEIs and millions of outcome rows at Scale500k), costing ~85
minutes of cumulative database time per large run, and again on every
visit to a completed Activity.

Stats are now maintained as incremental (ActivityId, Dimension, Key)
counter rows, upserted by the bulk RPEI/outcome persistence paths with
a deadlock-safe multi-row ON CONFLICT accumulate, and finalised to
exact values (recomputed from the persisted rows) when the Activity
reaches a terminal status. GetActivityRunProfileExecutionStatsAsync
reads counters for in-progress and finalised Activities; completed
Activities predating the counter table aggregate once and are
finalised lazily on first read. In-flight counters are advisory (the
only known drift source is post-insert ErrorType changes during
confirming-import reconciliation); finalisation reconciles exactly,
and a finalisation failure never blocks Activity completion.

This is the derivation-side enabler for #202/#307 (real-time progress
push): the counter row is exactly the payload a progress API or
SignalR publisher needs, and the batch-flush upsert site is where a
future NOTIFY goes.

TDD: calculator unit tests, ActivityServer finalisation-ordering tests
(red first via new APIs), and a RequiresPostgres fixture proving the
single-connection and parallel COPY insert paths write counters, the
in-progress stats read serves from counters (skew test), finalisation
reconciles deliberately skewed counters, and legacy Activities
lazily finalise. The fixture also points the JIM_DB_* variables at the
scratch database so the parallel write path cannot touch a live one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The usage stamp ran a tracked read-then-save on the same ApiKeys row for
every authenticated API request. During the Scale500k25kGroups validation
run (2026-07-21), the confirming import's write bursts briefly stalled the
database; queued stamp writes convoyed on the row lock past the 30s command
timeout, and EF's SaveChangesFailed diagnostic logged the tolerated
best-effort failures at error level, tripping the integration error watcher
and aborting the run.

RecordUsageAsync is now a single ExecuteUpdateAsync with the throttle
threshold in the predicate (at most one stamp per key per 30 seconds):
no SELECT round trip, no change tracker, no SaveChangesFailed event on
failure, and concurrent stamps collapse to no-ops instead of queueing.
In-memory provider falls back to a tracked update with identical
semantics, mirroring IncrementAggregatedFailedAuthenticationAsync.

TDD: in-memory behavioural tests (throttle red against the old
always-overwrite implementation) plus a RequiresPostgres fixture proving
the relational set-based path stamps, throttles, and re-stamps after the
interval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three defects where the module and its docs disagreed. In each case the
documented behaviour was the desirable one, so the cmdlet was fixed
rather than the doc downgraded, except where the behaviour could not
honestly exist.

Get-JIMScheduleExecution silently ignored a piped Schedule. Its
-ScheduleId took pipeline input by property name, but a Schedule
exposes Id, so nothing bound, the filter was skipped, and the caller
received every execution in the system whilst believing they had
filtered on one Schedule. An [Alias('Id')] cannot fix this: the cmdlet
already has its own -Id parameter, and PowerShell rejects a parameter
alias colliding with another parameter's name outright, even across
mutually exclusive sets. Adds -InputObject instead, preferring an
explicit -ScheduleId when one is supplied, so objects carrying a
scheduleId property keep their existing binding.

Reset-JIMServiceSetting was documented as accepting pipeline input but
its Key parameter carried no pipeline attribute, so the documented
piping example prompted for a mandatory parameter instead of running.

Invoke-JIMExampleDataTemplate's -Wait was documented as blocking until
generation completed; it only emitted a warning saying it was not
implemented. It cannot be implemented as things stand, because the
execute endpoint returns a bare 202 with no body, leaving the client no
handle to poll. The switch is removed and the asynchronous behaviour
documented, pointing at Activities for monitoring.

Also reconciles docs/powershell/run-profiles.md, which was written
against an earlier cmdlet design: wrong parameter names and invalid
ValidateSet values, parameter sets that do not exist, Guid fields typed
as int, and undocumented parameters. Two examples used -Id against
Get-JIMRunProfile, where Id aliases ConnectedSystemId; one of them
piped the result into Remove-JIMRunProfile -Force, which would have
deleted every Run Profile on that Connected System rather than one.

Pester: 1093 passing, 0 failing (baseline 1070).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Pipeline deletion" understated what the example does. Get-JIMConnectedSystem
-Name supports wildcards, so "Decommissioned*" piped into
Remove-JIMConnectedSystem -Force deletes every matching Connected System and
its connector space, with no prompt.

Same shape as the Run Profile example corrected in c8b1442: a reader's
mental model of "delete the thing" against a behaviour of "delete everything
matching". Found by sweeping every destructive pipeline example in the
PowerShell docs after that fix; the others are either scoped to an exact
identifier or already say "multiple" in the title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JayVDZ and others added 5 commits July 21, 2026 07:37
Adds scripts/Lint-DocExamples.ps1, run alongside the changelog and
docs-coupling lints.

A PowerShell alias serves both binding paths, so [Alias('Id')] on
Get-JIMRunProfile's -ConnectedSystemId parameter, added so a piped
Connected System binds, also makes "-Id 42" a valid command-line
spelling. A doc example reading "Get-JIMRunProfile -Id 42" therefore
looks like "Run Profile 42" and means "every Run Profile on Connected
System 42"; one shipped example piped exactly that into
Remove-JIMRunProfile -Force. Removing the alias is not an option, as it
would break the documented piping, so the lint requires documentation
to spell parameters out instead.

Builds the cmdlet/alias/real-name map from the module's AST, then parses
the extracted example code with the same parser rather than matching
text, so continuations and pipelines resolve and -Id cannot collide with
-Identity. Only ```powershell fences and Get-Help .EXAMPLE blocks are
scanned; parameter tables and prose legitimately document these aliases
and are structurally out of reach.

Currently reports zero violations across 603 fenced blocks and 451
examples, so it lands as a regression gate rather than a backlog.

Verified against adversarial fixtures beyond the agent's own suite: the
original defect is caught with the correct real-parameter name, an alias
mid-pipeline and across backtick continuation is caught with accurate
line attribution, and an alias documented in a parameter table, a
genuine -Id parameter, and a non-PowerShell fence all pass clean.

Pester: 1102 passing, 0 failing (baseline 1093).

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

Adds src/JIM.PowerShell/CLAUDE.md, which auto-loads when working in the
module subtree. Every rule in it was broken and shipped during the
recent audit; two of the resulting examples destroyed data when
copy-pasted.

It leads with why the module needs its own rules: PowerShell resolves an
unknown property to $null rather than erroring, and $null is falsy, so a
wrong property name in a negated filter matches every row instead of
none. That is how "remove all disabled certificates" became "remove all
certificates". Nothing in dotnet build, dotnet test or Pester detects it.

Covers the parameter-alias hazard in full, since it has no mechanical
guard at the point of declaration: why aliases exist (pipeline binding
matches on property name), that an alias cannot be pipeline-only so it
always becomes a command-line spelling too, that removing one to fix the
ambiguity would break documented piping, that PowerShell rejects an 'Id'
alias outright when the cmdlet has its own -Id parameter, and the
-InputObject pattern to use when it does. Also documents output shapes
differing per parameter set, destructive-example labelling, keeping docs
and behaviour in agreement, and Pester practice.

Separately, docs/CLAUDE.md asserted that cmdlet parameter tables live in
autogenerated help "not in mkdocs". No generation exists anywhere in the
repository, and those pages hand-maintain 37 to 61 table rows each, so
the policy described a system that was never built whilst the real,
drift-prone duplication went unacknowledged. That duplication is the
root cause of the audit's findings. Corrected to state the exception and
name generation as the fix that would remove the class of defect.

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

Sequencing uses the REST blocked-by dependency endpoints, containment
uses the GraphQL addSubIssue/removeSubIssue mutations; plain gh issue
commands expose neither, which is how prose comments crept in as the
relationship mechanism. Bodies keep the rationale only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JayVDZ
JayVDZ enabled auto-merge (squash) July 21, 2026 11:01
Comment thread src/JIM.PostgresData/Repositories/ActivitiesRepository.cs Fixed
Comment thread src/JIM.InMemoryData/SyncRepository.cs Fixed
Redundant ToString() call, missed Where/Select opportunities, and
ContainsKey+indexer instead of TryGetValue; no behavioural change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@JayVDZ
JayVDZ merged commit 5c63eed into main Jul 21, 2026
17 checks passed
@JayVDZ
JayVDZ deleted the feature/openldap-scenario8-membership-batching branch July 21, 2026 11:29
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.

1 participant