Skip to content

Shard-map invoke payloads exceed the 256 KB async cap β€” normalize to ids-only + zstd envelope; presigned-URL staging as the durable fixΒ #320

Description

@espg

πŸ€– from Claude

Problem

Shard-map invoke payloads are hitting the Lambda async event cap (256 KB). The orchestrator holds no S3 credentials (deliberate divide: lambdas write, the orchestrator doesn't), so today the only channel for a shard map is the invoke payload itself β€” and measured against the real benchmark fixtures, the as-shipped JSON is over the cap across the board, not just on monster shards:

fixture (tests/data/benchmark/shardmaps/) granules JSON as b64 event vs 256 KB
sm_healpix_o10_88s.json (1 shard) 4,605 2.15 MB 8.4Γ— over
sm_healpix_o9_88s.json (1 shard) 5,620 2.62 MB 10.2Γ— over
sm_healpix_o11.json (22 shards) 737 347 KB 1.35Γ— over

(b64: an event payload is JSON, so binary/compressed bytes pay a 4/3 base64 tax; all "event" numbers below include it.)

Why the payload is fat: derivable-URL denormalization

Each granule record is {id, s3, https} β€” and both URLs are derivable from the id (same filename; the date path parses out of ATL03_YYYYMMDDHHMMSS…). ~89% of every record is redundancy. That shape also decides the format question below.

Measured options (real fixtures, zstd level 3 via pyarrow)

encoding o10_88s (4,605) o9_88s (5,620) o11 (737)
json as shipped (event) 2.15 MB ❌ 2.62 MB ❌ 347 KB ❌
json + zstd (event) 93 KB βœ… 23Γ— 111 KB βœ… 24Γ— 4.9 KB βœ… 70Γ—
parquet + zstd (event) 172 KB βœ… 12.5Γ— 205 KB βœ… 13Γ— 8.6 KB βœ… 40Γ—
json ids-only (event) 259 KB ❌ 8.3Γ— 316 KB ❌ 8.3Γ— 43 KB βœ… 8Γ—
json ids-only + zstd (event) 45 KB βœ… 48Γ— 54 KB βœ… 48Γ— 2.4 KB βœ… 144Γ—

Notable:

  • Parquet-over-the-wire loses to plain zstd-JSON β€” by 2Γ—. On string-heavy, cross-record-redundant records, whole-blob zstd exploits the redundancy globally; parquet pays per-column page overhead and per-value string lengths at these sizes. (Parquet's wins β€” columnar scans, selective reads β€” don't apply to "decode the whole map once in a worker".) An "orchestrator sends parquet, worker re-emits JSON" pipeline was considered and is dominated on this data; it also would have squeaked under the cap with only ~3% headroom at the 6k-granule scale on synthetic records.
  • Normalization alone is not enough (o9_88s ids-only is still over) β€” it needs the zstd too.
  • The records carry no geometry β€” the one shape where parquet/WKB would win. If footprints ever move into these payloads, re-measure.

Recommendation (sequenced)

  1. Now: ids-only + zstd envelope. Ship granule ids plus URL templates in metadata; workers reconstruct s3/https (two-line template expansion β€” the collection is already known). Wrap every invoke in a versioned, unconditional envelope, e.g. {v: 1, codec: "zstd+json", data: <b64>} β€” no size threshold: a threshold is two codepaths plus a cliff rediscovered in production. Add an orchestrator-side size guard that fails loudly before the Invoke API rejects. Result: worst real fixture becomes a 54 KB event (48Γ—); headroom extrapolates to ~25–30k granules/shard.
  2. Durable: presigned-URL staging (claim-check that honors the creds divide). A lambda mints a scoped, expiring presigned PUT URL (tiny sync invoke); the orchestrator HTTP-PUTs the map directly β€” never holding S3 credentials β€” and async-invokes workers with the object key + size/hash. Removes the ceiling category entirely; events stay small, loggable, replayable; the at-rest format becomes a pure reader-side choice. (A simpler "stager" sync-invoke variant β€” worker PUTs the 6 MB-capped sync payload and returns a key β€” works but caps at 6 MB and moves the bytes through Lambda twice.)

Rejected: parquet/arrow-IPC on the wire (dominated, above β€” arrow IPC additionally doesn't fit at 6k granules); threshold-switched dual formats (permanent two-path reader complexity for every consumer, cliff merely postponed).

Why the durable fix matters even at 48Γ—

The _88s fixtures are the polar-convergence shard: ICESat-2's 92Β° inclination drives essentially every track through the 88Β°S ring, so that shard's granule count grows ~linearly with mission duration. ~25–30k granules of headroom is comfortable today and is still a date, not a fix β€” which is what item 2 is for.

Side note: this closes the "why is the root MOC JSON?" question too

The same measurement run covered mortie spec Β§7.3 (coverage.moc root ranges): on real Antarctic-basin covers, the spec's runs transform does the heavy lifting (12–98Γ— cardinality reduction, growing with order), leaving the worst matrix case (191k cells) at 70 KB of spec JSON for a read-once-per-open object β€” with parquet saving only ~64 KB more at the cost of a browser-side arrow-wasm dependency (gridlook phase 6 reads it with JSON.parse) and a spec change. The spec's tiering is already right: declarations in JSON, the size-sensitive tier (leaf bitmaps, Β§7.2) already binary+zstd, bulk cell data in arrow/parquet via the interchange docs. No change recommended there.

Repro

Fixtures: tests/data/benchmark/shardmaps/. Encoders: zstd via pyarrow.compress (level default), parquet compression="zstd" with a shard-index column preserving bucketing. Numbers above are from the current fixtures on main; the MOC-side numbers are from mortie main (morton_coverage flat covers at orders 8/10/12, Β§7.3 run computation, Β§1 word layout).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions