Task: Add gzip compression to the image NATS pipeline, send raw bytes instead of base64

Table of Contents

This page documents a task in the Fix image-batch NATS payload overflow, add SVG compression story. It captures the goal, current status, acceptance, and any notes or results.

Goal

Two distinct mechanisms, both scoped to image-carrying messages only (the rest of the messaging protocol's JSON/=rfl::json= transport is out of scope for this task – see the "Consider compressing all NATS JSON messages" backlog capture for that broader, separate question):

  1. Stop round-tripping SVG bytes through base64-inside-JSON just to decode them straight back out again on the other end. Image data should travel as raw bytes on the wire, not as a base64 string field the receiver immediately decodes – that encode/decode step is pure overhead with no benefit for this payload.
  2. Compress those raw bytes (gzip) before sending; decompress on receive. This is what actually shrinks the payload – the base64 removal alone only recovers the ~33% encoding inflation, whereas gzip on SVG text typically saves 70-80% on top of that.

Together these should make image responses small enough that MAX_IMAGES_PER_REQUEST is no longer the binding constraint (see the sibling byte-size-aware-batching task for the remaining backstop).

Status

Field Value
State DONE
Parent story Fix image-batch NATS payload overflow, add SVG compression
Now Nothing.
Waiting on Nothing.
Next Nothing.
Last touched 2026-07-26

Acceptance

  • [X] Image responses are compressed on the wire (server compresses, client decompresses) – implemented generically at the NATS transport layer rather than image-specifically; see Plan/Notes for why that's sufficient and preferable.
  • [ ] Image data travels as raw bytes instead of base64-in-JSON – descoped from this task; not load-bearing given the compression margin, tracked separately as a follow-up task (see Notes).
  • [X] Worst-case batch verified via unit test against a synthetic payload sized like the decision task's measured worst case.

Plan

Implemented as a generic, transparent transport-level mechanism in ores.nats rather than an image-specific change, after finding that every inbound/outbound NATS message in the codebase already funnels through two choke points:

  • extract_message() (ores.nats/src/service/client.cpp) builds ores::nats::message from every raw natsMsg* – both request-reply replies and subscription callbacks, client and server side alike.
  • make_msg() (same file) builds every outbound natsMsg* – used by publish(), request_sync(), and js_publish().

Added ores.nats/domain/compression.hpp=+.cpp=:

  • compress_if_worthwhile(data, headers) – gzips (reusing the existing ores::utility::compression::gzip_compress, the same one ores.storage's HTTP transport already uses) and sets the new X-Content-Encoding: gzip header, but only when: the payload is >= 4096 bytes (compression_threshold_bytes; below this gzip's ~20-byte framing overhead and the CPU cost isn't worth it – the vast majority of NATS traffic is small request/response JSON and pays zero cost), the caller hasn't already set a content-encoding header, and compressing would actually shrink the payload (safety net).
  • decompress_if_flagged(data, headers) – the inverse, called unconditionally from extract_message().

Wired into both choke points. Result: every message above the threshold – not just images – gets transparent compression, with zero changes to ImageCache, ClientManager, any protocol struct, or any domain type. Pulled the logic into a proper testable header+impl (rather than leaving it extract_message()=/=make_msg()- local) specifically so it has real unit test coverage without needing a live NATS server.

Also found and fixed, in a separate commit on this branch (unrelated to compression, but blocking compass db recreate for anyone on current main): three SQL codegen templates (sql_schema_table_create, sql_schema_junction_create, sql_schema_artefact_create) rendered a column's SQL default via the double-mustache {{default}}, which HTML-escapes its output – turning the quotes a text default gets wrapped in (e.g. 'quantlib_computed') into ' entities and producing invalid SQL. sql_schema_domain_entity_create already used the correct raw {{{default}}} form.

Notes

Scope was narrowed from the task's original title/description (which named both compression and raw-bytes-instead-of-base64) after weighing the tradeoff with the story owner: the decision task's own data shows generic gzip alone (keeping rfl::json's base64 encoding as-is) already gets a 2.5x margin against the worst measured case – comfortably enough. Raw-bytes-instead-of-base64 is real but not load-bearing (a further ~25% improvement) and requires a much more invasive, image-specific wire-encoding change (bypassing rfl::json's default std::vector<uint8_t>-to-base64 behaviour). Split into a separate follow-up task rather than expanding this one's scope.

Test Scenarios

Manual QA scenarios (scaffolded via compass add test_scenario, run through the QA Validation Runner panel) that verify this task. Link new ones here as they're created; the scenario doc itself links back via its "Verifies task" field.

Scenario State Notes
     

PRs

PR Title
#1706 [nats] Add transparent transport-level gzip compression for image-batch payloads

Review

# Comment summary File Decision Notes
1 on_msg() calls extract_message() (can now throw) before its try block – crash/DoS via a malformed gzip-flagged message client.cpp Fixed Moved the call inside try, with single-destroy handling of the natsMsg* across success/exception paths; added a test pinning that decompress_if_flagged throws on invalid gzip.
2 No test for decompress_if_flagged with the gzip header set on a non-gzip payload domain_compression_tests.cpp Fixed Same commit as #1.

Result

Shipped a generic, transparent transport-level gzip compression mechanism in ores.nats (domain/compression.hpp=+.cpp=, wired into make_msg()=/=extract_message()), rather than an image-specific change – every NATS message above 4096 bytes now gets compressed automatically, with zero changes to ImageCache, ClientManager, or any protocol/domain type. 7 new unit tests in domain_compression_tests.cpp cover the threshold gating, the compression-doesn't-help safety net, header precedence, and full round-trip correctness including a synthetic worst-case-sized payload.

Raw-bytes-instead-of-base64 (the task's other originally-named mechanism) was descoped as not load-bearing and split into a separate follow-up task – see Notes.

Also fixed an unrelated regression discovered while recreating the local database to verify this change: three SQL codegen templates were HTML-escaping quoted column defaults into invalid SQL, breaking compass db recreate for anyone on current main. Fixed in a separate commit on this branch (1cb431617).

Full local verification: ores.nats.tests (including the 7 new cases) and the complete ctest suite both green, 74/74 tests passing on linux-clang-debug-make.

Emacs 29.3 (Org mode 9.6.15)