Skip to content

Key-fanout CDM import baseline ​

Slice 1 of plans/2026-10-27-cdm-import-batch-apply-brief.md. Existing harness only: pnpm --filter grant-api measure:cdm-import -- --repeat key-fanout. No production apply code changed. This page does not invent a p50/p95/p99 and does not publish a Lambda-fit SLA.

The diagnosis (plans/2026-10-27-cdm-import-performance-diagnosis.md) remains order-of-magnitude for Lambda→RDS (~11 min first import). The numbers below are this host.

Replace + hash-prep (follow-on story): cdm-import-key-fanout-replace.md. --repeat below is merge, not replace.

Environment ​

FieldValue
Date2026-10-27
Apply SHA588183f9 (origin/main; this slice does not change apply)
FixturegenerateKeyFanoutCdm() / profile key-fanout — not enterprise / entropy-bound
CommandNODE_ENV=test DB_URL=… pnpm --filter grant-api measure:cdm-import -- --repeat key-fanout
DatastoreDisposable PostgreSQL 16 on localhost:5432, migrated and seeded with the e2e scripts. Docker was not available on this Cloud Agent VM, so pnpm test:e2e:up could not start; same migrate/seed path.
RLSOn (SET LOCAL inside the measure transaction, matching the worker)
HashTOKEN_BCRYPT_ROUNDS=10
CacheIn-process memory (CacheFactory.createEntityCache({ strategy: 'memory' }))
HostLinux 6.12, 4 CPUs, 16 GiB RAM. Postgres and the Node process share the machine (not Lambda→RDS).

clientId is omitted on the fixture keys. A first attempt that set clientId finished the first pass and then failed the merge-repeat with ConflictError: clientId is already in use for this scope — merge always creates new key rows, and scoped uniqueness stays fail-closed. BYOK cost is the secret hash + insert.

Results ​

ProfileEntitiesPassSnapshotImportTotalAppliedms/entityvs 15 min
key-fanout457first0.02 s65.72 s65.73 s4,415143.87.3%
key-fanout457repeat0.04 s65.26 s65.30 s4,393142.97.3%

Durations are snapshot + import inside one transaction, matching project-sync.job.ts. They exclude queue latency and the job-row writes that bracket them. The existing tool's "vs 15 min" column is the Lambda ceiling the harness already prints — not a new target from this slice.

SyncProjectResult counters ​

CounterFirstRepeatNotes
rolesCreated77Merge creates again
groupsCreated5757
roleGroupsLinked00Roles have no group path
groupPermissionsLinked494494
projectRolesLinked77
projectGroupsLinked5757
projectPermissionsLinked336336
projectResourcesLinked4646
projectUsersEnsured110Repeat: already members
usersCreated110Repeat: email identities already exist
userRolesAssigned1,8831,883Listings, not distinct (user, role) pairs
projectUserApiKeysCreated1,1241,124
resourcesCreated4646
permissionsCreated336336
tagsCreated00

Password counters stayed 0. Tag / role-group counters stayed 0.

Repeat applied 4,393 vs first 4,415: the 22 missing increments are usersCreated (11) + projectUsersEnsured (11).

Statement / hot-function sketch ​

pg_stat_statements over both passes (reset immediately before the command):

Value
SQL calls59,047
Distinct queries138
SQL exec time1.87 s
Wall (first+repeat)131.03 s

About 29.5k statements per pass, in the diagnosis's 20k–30k band. SQL was ~1.4% of wall clock on this host. The rest is process time (bcrypt-10 hashSync on 1,124 secrets per pass, plus sequential await on the service loop).

Hottest statements by total exec time (both passes):

CallsTotalMeanShape
1,724732 ms0.42 msSELECT all project_permissions for the project (list-all N+1)
2,248157 ms0.07 msINSERT api_keys
2,248147 ms0.07 msINSERT project_user_api_keys
2,248121 ms0.05 msINSERT api_key_audit_logs
2,24893 ms0.04 msINSERT project_user_api_key_audit_logs
3,76637 ms0.01 msSELECT user_roles by user_id (addUserRole full list)
3,93424 ms0.01 msSELECT roles by id
98839 ms0.04 msINSERT group_permissions

2,248 key inserts = 1,124 × two passes. User-role listing volume sits in the user_roles / roles selects (thousands of calls against 7 templates × 11 users).

On this host the repeat snapshot stayed cheap (0.04 s). That does not calibrate Lambda→RDS export of the same graph; it is the local number.

What this is not ​

  • Not a p95. One first pass, one repeat.
  • Not a new Lambda-fit target. Do not scale 65.73 s by a guessed RDS RTT factor.
  • Not the ADR 0002 28,880-entity / 62.3 min row (inverse shape: many users, few keys).
  • Not a reason to skip slice 2. The sequential service loop and bcrypt-in-TX are still the apply path; this slice only wrote the key-fanout before-picture.

After slices 2 and 3 (batch pivots + batch keys) ​

Same harness, same fixture, same host, same disposable PostgreSQL 16. Apply SHA e99635c9 on feat/cdm-import-batch-apply-keys (#617, stacked on #615). Command: NODE_ENV=test DB_URL=… pnpm --filter grant-api measure:cdm-import -- --repeat key-fanout. pg_stat_statements reset immediately before the command. No p50/p95/p99. No Lambda-fit SLA.

The measure tool calls importProjectCdm inside the snapshot+import transaction without withHashedCdmApiKeySecrets. Hashing 1,124 secrets at bcrypt-10 is therefore inside import seconds (async hashSecretAsync, parallel on this host). The worker hashes before withTransaction; worker import seconds would exclude that CPU.

ProfileEntitiesPassSnapshotImportTotalAppliedms/entityvs 15 minvs slice 1
key-fanout457first0.05 s17.30 s17.35 s4,41538.01.9%−73.6%
key-fanout457repeat0.04 s17.16 s17.20 s4,39337.61.9%−73.7%
FieldSlice 1 (588183f9)After 2+3 (e99635c9)
First total65.73 s17.35 s
Repeat total65.30 s17.20 s
First snapshot0.02 s0.05 s
Repeat snapshot0.04 s0.04 s
SQL calls (both)59,04720,438
Distinct queries138143
SQL exec time (both)1.87 s0.81 s
Wall first+repeat131.03 s34.55 s

Counters match slice 1: 7 roles, 57 groups, 494 group-permission links, 1,124 keys, 1,883 role listings, 11 users on first (usersCreated / projectUsersEnsured 0 on repeat). Repeat applied 4,393 vs first 4,415 — the same 22-increment gap. Password / tag / role-group counters stayed 0.

SQL is ~2.3% of the 34.55 s wall (was ~1.4% of 131 s). About 10.2k statements per pass, down from ~29.5k.

Hottest statements by total exec time (both passes):

CallsTotalMeanShape
674216 ms0.32 msSELECT all project_permissions for the project (list-all)
2,24856 ms0.02 msINSERT api_key_audit_logs
2,24848 ms0.02 msINSERT project_user_api_key_audit_logs
245 ms22.6 msINSERT project_user_api_keys (multi-row; 1,124 rows × 2 passes)
67238 ms0.06 msINSERT permissions
233 ms16.7 msINSERT api_keys (multi-row; 1,124 rows × 2 passes)

Slice 1's 2,248 single-row INSERT api_keys / INSERT project_user_api_keys are now two multi-row inserts. INSERT user_roles is two multi-row statements. INSERT group_permissions dropped from 988 calls to 114 batched inserts. The 3,766 SELECT user_roles / 3,934 SELECT roles shapes left the hot list.

Repeat is apply again, not snapshot: snapshot stayed 0.04–0.05 s; repeat import 17.16 s vs first 17.30 s. Merge still creates 7 roles, 57 groups, and 1,124 keys. That is the stack plan's skip rule for slice 4 — snapshot is not the remaining minutes.

Skip slice 4 ​

Skip feat/cdm-import-batch-apply-snapshot. First+repeat are a significant cut vs slice 1 (65.73 s / 65.30 s → 17.35 s / 17.20 s) and the repeat is explained by merge-creates-again plus bcrypt-10 of 1,124 secrets in the measure's import seconds. Snapshot is tens of milliseconds on this host, not minutes. Do not invent a Lambda export cost from this local snapshot.

Remaining apply time on this host is process time (hashing in the measure path, leftover sequential permission/project-permission writes). Cheaper KDF is out of this stack. This page still does not publish a Lambda-fit SLA.

Released under the MIT License.