What
A single POST /v1/ingest request can allocate ~20× its wire size as live heap, because the body is decoded into a map[string]any before anything checks the schema. The 16 MiB request-body cap bounds the wire size, not the decoded value.
This is pre-existing — it is not introduced by #553. main decodes the identical map from the stream. It is filed separately from #544 because that issue bounds the total across concurrent requests; this is about the multiplier on one request, which no cap currently touches.
Why the cap doesn't bound it
objectReader.Next decodes into map[string]any with whatever keys arrived (internal/api/record_reader.go:158). The only thing that knows the table's columns is h.validator().Validate, first reached at internal/api/ingest.go:448 — inside processRecord, with the whole map already materialized. So "a flat ingest row's key count is bounded by the table's columns" is true of a row that will be accepted, and irrelevant to peak memory: an unknown column is rejected only after the allocation that matters.
Measured
Live heap after runtime.GC(), using the handler's own decoder settings (json.Decoder + UseNumber() into map[string]any), 16 MiB bodies. Reproduced independently three times (once by me, once by each of two reviewers) with agreeing figures:
| body shape |
live heap |
ratio |
| flat object, ~1.4M one-byte keys |
114–163 MiB |
7.1–10.3× |
one array-valued key {"a":[1,1,…]} |
286 MiB |
17.9× |
{"a":[{},…]} |
357 MiB |
22.3× |
{"a":[{"b":"x"},…]} |
590 MiB |
36.9× |
| benign: one 16 MiB string value |
16 MiB |
1.0× |
Add the ~32 MiB buffer (a body above ~16 MiB − 512 B lands in a 32 MiB allocation) and one request peaks around 320 MiB against a 16 MiB cap. The tail is unbounded — nesting goes higher.
Total-allocation churn is worse than the live figures: 980 MiB for the array-valued-key case, which is GC pressure rather than resident bytes but still real.
Reachability
deployments/compose/settings/policies.json ships default_role: public with insert.allow_columns: ["*"] on clicks and events, so the shape needs no credential in the shipped demo configuration. Auth and policy both gate ahead of the body read, so a deployment that does not grant public insert is not exposed this way — but the shipped one is.
Options
Not proposing a specific fix; roughly in increasing cost:
- Bound the decoded value rather than the wire size — e.g. a key-count or depth limit applied during decode, or
json.Decoder.Token() streaming for the single-object path so unknown keys are dropped before they are stored.
- Validate columns during decode instead of after, so an unknown key is rejected as it is read. This is the structural fix and it fits the type-layer direction, since the type layer needs to know the column set anyway.
- Tighten the shipped compose policy so
public does not carry insert, which shrinks the unauthenticated surface without changing the multiplier.
- Document only. Already done as of
c807b460 — docs/src/content/docs/reverse-proxy.mdx now tells operators to size for "at least 10×" and names the ~18× array-valued-key case. That is a mitigation for someone who reads it, not a fix.
Notes
Documented behavior as of #553; see the sizing note under Request-body size limits in the reverse-proxy guide. Related: #544 (total in-flight bytes).
What
A single
POST /v1/ingestrequest can allocate ~20× its wire size as live heap, because the body is decoded into amap[string]anybefore anything checks the schema. The 16 MiB request-body cap bounds the wire size, not the decoded value.This is pre-existing — it is not introduced by #553.
maindecodes the identical map from the stream. It is filed separately from #544 because that issue bounds the total across concurrent requests; this is about the multiplier on one request, which no cap currently touches.Why the cap doesn't bound it
objectReader.Nextdecodes intomap[string]anywith whatever keys arrived (internal/api/record_reader.go:158). The only thing that knows the table's columns ish.validator().Validate, first reached atinternal/api/ingest.go:448— insideprocessRecord, with the whole map already materialized. So "a flat ingest row's key count is bounded by the table's columns" is true of a row that will be accepted, and irrelevant to peak memory: an unknown column is rejected only after the allocation that matters.Measured
Live heap after
runtime.GC(), using the handler's own decoder settings (json.Decoder+UseNumber()intomap[string]any), 16 MiB bodies. Reproduced independently three times (once by me, once by each of two reviewers) with agreeing figures:{"a":[1,1,…]}{"a":[{},…]}{"a":[{"b":"x"},…]}Add the ~32 MiB buffer (a body above ~16 MiB − 512 B lands in a 32 MiB allocation) and one request peaks around 320 MiB against a 16 MiB cap. The tail is unbounded — nesting goes higher.
Total-allocation churn is worse than the live figures: 980 MiB for the array-valued-key case, which is GC pressure rather than resident bytes but still real.
Reachability
deployments/compose/settings/policies.jsonshipsdefault_role: publicwithinsert.allow_columns: ["*"]onclicksandevents, so the shape needs no credential in the shipped demo configuration. Auth and policy both gate ahead of the body read, so a deployment that does not grant public insert is not exposed this way — but the shipped one is.Options
Not proposing a specific fix; roughly in increasing cost:
json.Decoder.Token()streaming for the single-object path so unknown keys are dropped before they are stored.publicdoes not carryinsert, which shrinks the unauthenticated surface without changing the multiplier.c807b460—docs/src/content/docs/reverse-proxy.mdxnow tells operators to size for "at least 10×" and names the ~18× array-valued-key case. That is a mitigation for someone who reads it, not a fix.Notes
Documented behavior as of #553; see the sizing note under Request-body size limits in the reverse-proxy guide. Related: #544 (total in-flight bytes).