previews / firm-migrations / instrumentation plan

Platform · Firm migrations

Instrumenting the import: plan

One dry run per firm plus a small table of measured constants should tell us the batch size and configuration for its real import, before we run it.

Status: Plan, not started

Follows from the dev dry-run report: a 19-minute run held 38 seconds of work, and the rest was Inngest dispatch gaps between batches. This plan adds the instrumentation to measure that split on every run, and defines the runs that turn the measurements into a per-firm recommendation.

TL;DR. Run time follows wall(B) ≈ (units ÷ B) × gap + Σ rows_e × rate_e × k_e. The gap term depends on the Inngest configuration, measured once per configuration on the fixture dump. The rows and rates come from one dry run of the firm's own dump. k_e is a dry-to-real cost multiplier per entity, calibrated once in dev with a dry run, a real run, and a teardown of the same dump. With those inputs the batch-size curve is arithmetic; no sweep needed. Three PRs build the instrumentation; the first one alone answers where the 37-second gap comes from.

What the scout found

Two facts change how much we have to build.

A batch ("hop") is three Inngest executions, not one: the step.run that does the work, the step.sendEvent that triggers the next batch, and the final return. Each execution re-enters the queue and contends for the same concurrency: 1 slot, so one batch pays up to four queue waits. The existing OTel middleware already emits a span per execution with inngest.run.id attached, so yesterday's gaps are queryable in Axiom today, with no code change. That query is the first task on this plan and needs nothing deployed.

One batch, on the wirequeue waitexecution 1 · the workwaitexec 2 · send nextwaitexec 3 · return…then the next batch's event re-enters the queue: a fourth wait before any new work.Every grey block contends for the same concurrency-1 slot. Measured in dev: the grey averaged 37s per batch, the work ~1.3s.

The heartbeat column already exists. ImportRun.lastProgressAt is in the schema with a comment saying the run module is expected to stamp it per batch. Nothing writes it.

Constraints the scout pinned down: OTel metrics are unusable in this stack (the exporter sends an encoding Axiom's metrics endpoint rejects), so tuning data lives in span attributes and the report, not a metrics pipeline. Span attributes are not PII-scrubbed, so spans carry ids and refs only. Prisma auto-instrumentation already emits thousands of spans per hop, so our own spans stop at unit granularity, with stage timings as attributes rather than child spans.

The model

wall(B) ≈ (units ÷ B) × gap  +  Σ_entity rows_e × rate_e × k_e

Batch size enters only through the hop count, so once the constants are measured the curve over B is arithmetic. The final report can print it: "this firm at batch 100 ≈ 19 min, at 500 ≈ 5 min, at 1000 ≈ 3 min".

wall(B) for the 714-matter dev firm · gap 37s, work 38sfloor: the work term, ~38sB=100 · 19 min (today)B=500 · 4.3 minB=1000 · 2.5 min1003005007001000batch size (units per hop)19m10m0

The same shape says where each lever stops paying: raising B divides the gap term but never touches the floor, and past the point where a hop holds a whole matter bucket the curve flattens early. Shrinking the gap itself (higher concurrency, or one execution per hop) lowers the whole curve. Density decides where a given firm sits on it.

The instrumentation

PR 1 — durable timings on the run row

PR 2 — spans

Via the tracer the Inngest middleware already injects into the function context (typed, no new dependency):

Every span carries runId: hops are separate traces and nothing links them, so the run id attribute is the only way to assemble a run-level view in Axiom.

PR 3 — surfaces

The measurement runs

gap query · todayyesterday's spans, no codegap table · per configfixture dump, 107 rowsdensity · per firmone dry run of its dumpcalibration · oncedry + real + teardown → kwall(B) = hops × gap+ Σ rows × rate × krecommendation per firmbatch size + config + ETAconfirmation runprediction printed next to actual
  1. Gap query, now. APL over the existing execution spans from the 714-matter run of 2026-08-20. Tells us which of the four queue waits dominates, which decides whether the first experiment is raising concurrency or collapsing the hop to one execution.
  2. Gap table. Fixture-dump runs, one per configuration under consideration (current; raised IMPORT_GLOBAL_CONCURRENCY; collapsed hop if we build it). The gap is size-independent, so the fixture's 107 rows suffice.
  3. Density profile. One dry run per firm at default settings. Produces units, rows_e, rate_e, matters per hop.
  4. Calibration. Dry run, real run, teardown of the same 714-matter dump against the dev test firm. Same dump and same instrumentation on both sides; dividing the per-entity work rates gives the k table. Teardown exists so the real run leaves nothing behind.
  5. Confirmation. One run at the recommended B per firm before its real import. Prediction printed next to actual.

Order of work

  1. The gap query (nothing to build).
  2. PR 1. From here every run collects tuning data.
  3. Calibration runs in dev (needs PR 1 only).
  4. PR 2, PR 3.
  5. Gap-table runs for any configuration change we decide to test.

Not in scope: fixing the OTel metrics exporter (spans and the report cover this need), per-query span sampling policy (a cross-service decision), and the run-creation checksum stream (tracked as its own item on the dry-run report's next-work list; the scout added that it downloads the entire dump inside the operator's tRPC call).