Skip to content

perf(crud): bulk batch POST via a single multi-row INSERT (createMany) #451

Description

@Justus-at-Tazama

Repo: tazama-lf/admin-service (branch dev)
Follow-up to: #436 (atomic batch POST), #450 (consistent batch reply shaping). Surfaced while testing the rule/typology batch POST against a remote database.

Problem

The atomic batch (array) POST for rule and typology (#436) inserts items one row at a time: the handler pins a single transaction client and loops repo.create(item, tenantId, client) serially (see buildCrudPlugin in src/utils/crud-schema.ts and TypologyConfigRepo.create in src/repositories/configuration/typology.config.repository.ts). Because a single pg connection serialises queries, an N-item batch costs roughly N database round-trips. Over a remote database link this is the dominant latency - a modest typology batch can approach multi-second response times even though each individual insert is cheap.

Naive parallelism does not fix this:

  • Promise.all of client.query(...) on the same pinned client gives no concurrency - pg queues queries on one connection and runs them sequentially.
  • Spreading inserts across multiple pooled connections would gain concurrency but break the single BEGIN/COMMIT, losing the all-or-nothing atomicity guarantee that the batch feature exists to provide (feat: allow batch (array) submission of configurations on POST endpoints #436).

Goal

Collapse the per-item inserts into a single parameterised multi-row INSERT per batch, so an N-item batch is one round-trip instead of N, while preserving atomicity and the existing response contract.

INSERT INTO typology (configuration) VALUES ($1),($2),...,($n) RETURNING configuration

A single statement is inherently atomic, so the all-or-nothing guarantee holds (a constraint violation on any row aborts the whole statement) without relying on the explicit transaction wrapper for correctness.

Approach (locked after pre-implementation review - see the "Resolved questions" comment below)

  1. Add a createMany(payloads, tenantId, client?) method to the batch-enabled repositories (rule, typology), duplicated per repo with fully literal SQL, matching the existing get/create/update/remove duplication (Q4). It:
    • stamps tenantId, creDtTm, updDtTm on each payload exactly as create does today, using one shared timestamp for the whole batch (Q1);
    • builds one parameterised INSERT INTO <table> (configuration) VALUES ($1),($2),...,($n) RETURNING configuration - placeholders generated from the payload count, values bound positionally, no string interpolation of data;
    • returns the created entities in input order (Postgres emits RETURNING rows in insertion order for a plain non-parallel multi-row VALUES - deterministic, stable behaviour, accepted reliance);
    • has no empty-array guard: the schema's minItems: 1 rejects at the boundary and the batch handler is createMany's only caller.
  2. Type system (Q3): createMany? is an optional method on the CrudRepository interface, following the existing optional-capability precedent (activate?/deactivate?/getActive?), with a fail-fast guard at plugin registration when batch is configured but the repo lacks createMany.
  3. The generic batch handler in buildCrudPlugin calls createMany once for the whole array instead of looping create, keeping the injected runInTransaction wrapper (Q2): the single statement is atomic on its own; the wrapper is retained for interface stability and to future-proof multi-statement batches (noted with a one-line comment).
  4. Keep the response contract identical to feat(service-channel): fire-and-log ack sink on reply subject (#445) #450: a 201 JSON array in input order, each element serialised through the route's Entity serialiser (consistent with a single create), and Content-Type: application/json.
  5. Respect the existing maxItems cap (default 200, a startup-time constant frozen into the compiled schema - not a runtime knob) and the per-item validation that reports item[<index>] on the first invalid element (Q5).

Acceptance criteria

  1. rule and typology batch POST insert the whole array in a single multi-row INSERT (one database round-trip for the rows), not N serial inserts.
  2. Atomicity is preserved: a failure on any row (e.g. unique-constraint violation) inserts none of the batch - all-or-nothing, asserted by test.
  3. The response is unchanged from feat(service-channel): fire-and-log ack sink on reply subject (#445) #450: 201, a JSON array in input order, each element schema-serialised identically to a single-object create, Content-Type: application/json.
  4. Per-item validation and the maxItems cap behave exactly as today (first invalid item -> 400 item[<index>]: ...; empty array and over-cap -> 400).
  5. Values are bound positionally; no batch data is ever string-interpolated into SQL (no injection surface) - asserted structurally: exact-equality on the statement text as a pure function of the item count, data present only in values, plus a hostile-payload probe.
  6. Red-tests-first: failing unit tests are written first and reviewed before any production code. They must cover: single multi-row statement is issued (one query/round-trip for N rows), input-order preservation in the response, all-or-nothing rollback on a mid-batch failure, validation/cap behaviour unchanged, and the response shape/content-type contract.
  7. Docs: update the README "Configuration POST contract (single or batch)" section to describe the single multi-row INSERT and its atomicity, and update any affected OpenAPI/Swagger notes for explainability and maintainability.

Out of scope

  • Cross-entity batching (each entity type keeps its own statement/table).
  • network_map batch (still deliberately single-object only, feat: allow batch (array) submission of configurations on POST endpoints #436).
  • Streaming/COPY-based ingestion or chunking beyond the existing maxItems cap.
  • Configurable maxItems overrides (env var, per-request, or per-route): the fixed startup-time cap of 200 is significant enough for now.
  • DB-error-to-HTTP mapping (e.g. unique violation 23505 -> 409): none exists today - a mid-batch failure surfaces as Fastify's default 500 and the single statement preserves that contract unchanged. Adding a mapping is a separate issue.

Cross-cutting

Exact-RC pins; red-first TDD with the database/transport mocked at the unit level; minimal, explanatory-only comments; update the README and Swagger docs.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions