Skip to content

Key-fanout CDM replace hash-prep baseline ​

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

The merge first+repeat numbers in docs/benchmarks/cdm-import-key-fanout.md are a different strategy on a path that hashed inside import seconds. They do not stand in for a worker replace.

Environment ​

FieldValue
Date2026-10-27
Apply SHA8bead44a (origin/main, includes #618). This slice logs hash-prep; it does not change create/teardown.
FixturegenerateKeyFanoutCdm() / profile key-fanout — not enterprise / entropy-bound
CommandNODE_ENV=test DB_URL=… TOKEN_BCRYPT_ROUNDS=10 pnpm --filter grant-api measure:cdm-import -- --replace 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).

--replace imports once (merge into an empty project), then imports the same document with mode.strategy = replace + confirmDestructive. --repeat (merge-creates-again) was not used on this run so the two strategies stay distinct.

One-thread hash-prep ​

hashCdmApiKeySecretsSerially — hashSecret / bcrypt.hashSync, cost 10, one secret after another. This is the 1-CPU number. Multi-core Promise.all (hashSecretAsync) is not this row.

ProfileSecretsHash-prepms/secret
key-fanout1,12455.80 s49.6

Worker-shaped import (hash-prep then TX then invalidate) ​

Each pass hashes with withHashedCdmApiKeySecrets (async bcrypt, libuv threadpool on 4 CPUs) before snapshot+apply, then times invalidateCachesForImportResult. Totals include those phases. They exclude queue latency and markCompleted.

ProfileEntitiesPassSecretsHash-prepSnapshotApplyPost-commitTotalAppliedvs 15 min
key-fanout457first1,12414.09 s0.03 s3.65 s0.00 s17.77 s4,4152.0%
key-fanout457replace1,12414.04 s0.12 s3.74 s0.00 s17.90 s4,3932.0%

Hash-prep is 79% of the replace total on this 4-CPU host (14.04 / 17.90). Apply after the hashes exist is 3.74 s. One-thread hash-prep (55.80 s) is 15× apply.

SyncProjectResult counters ​

CounterFirstReplaceNotes
rolesCreated77Replace teardown + recreate
groupsCreated5757
roleGroupsLinked00Roles have no group path
groupPermissionsLinked494494
projectRolesLinked77
projectGroupsLinked5757
projectPermissionsLinked336336
projectResourcesLinked4646
projectUsersEnsured110Users already members; replace does not re-provision them
usersCreated110Email identities already exist
userRolesAssigned1,8831,883Listings, not distinct (user, role) pairs
projectUserApiKeysCreated1,1241,124Replace still creates every key after teardown
resourcesCreated4646
permissionsCreated336336
tagsCreated00

Password counters stayed 0 (fixture has no password hashes). Tag / role-group counters stayed 0.

Replace applied 4,393 vs first 4,415: the 22 missing increments are usersCreated (11) + projectUsersEnsured (11). Keys did not shrink — projectUserApiKeysCreated is 1,124 on both passes.

Verdict ​

Hash-prep is the remaining minutes on an unchanged replace. After #618 the apply/TX work for this fixture is a few seconds. The worker still bcrypt-10s every BYOK secret before that TX, and replace still reports 1,124 keys created. On this host:

  • 4-CPU async hash-prep: 14.04 s of a 17.90 s replace
  • 1-thread sync hash-prep: 55.80 s (49.6 ms/secret × 1,124)

That 1-thread row is the one that maps toward ~0.6–1 vCPU Lambda, not the 14 s Promise.all. Diagnosis 2–4 min on a throttled Lambda remains order-of-magnitude; this page does not invent a Lambda-fit SLA or scale 55.80 s by a guessed factor. Slice 2 (fingerprint + skip) is required — do not skip it, and do not lower TOKEN_BCRYPT_ROUNDS.

Post-commit invalidate was 0.00 s here (11 members, memory cache). It does not explain a ~40 s Lambda invocation tail; that stays an optional later look.

What this is not ​

  • Not a p95. One first pass, one replace, one serial hash-prep.
  • Not a new Lambda-fit target.
  • Not the merge --repeat row from docs/benchmarks/cdm-import-key-fanout.md.
  • Not a cheaper KDF. Rounds stay 10.

Released under the MIT License.