← 1.0.0 scope SAPE-33

concatRange — join a stretch of values without shipping them

Status
To do
Component
server
Priority
Medium
Blocked by
SAPE-31 — the size ceiling
Blocks

What it is

Read the values of a key range in key order, join them, and write the result to one key — as one atomic operation, with the reading and the writing on the same side of the wire. The use it comes from is concrete: a ten-megabyte file stored as thousands of two-kilobyte rows, on a server with little memory, where reassembling in the application means moving all of it twice.

Like SAPE-32, this is an engine primitive exposed as a declared operation. Nobody writes an expression: the caller supplies a range and a destination, and the shape is fixed when the operation is declared. A rollup is the precedent — the engine computes, the driver declares.

Layer one — should the engine have it? Yes, but not first

The engine question is whether the cost can be bounded and whether it is safe for the server, and this is where concatRange differs sharply from deleteRange.

Its output is unbounded until something bounds it. A range of N rows produces one value of roughly the sum of their sizes, and the engine has to hold it to write it. There is no maximum value size today — that is SAPE-31 — and a frame length is a uint32. So on the exact machine this feature is for, a server with little memory, an unbounded concatRange is the hazard rather than the fix.

It therefore needs two ceilings, both declared: a limit on how many rows it reads, and a maximum on the bytes it may produce. Refusing when either is exceeded is the honest answer; producing a truncated value would be the dishonest one. Without them the cost is not knowable before the call, and that is what would breach the promise — not the primitive existing.

The recommendation: hashRange should come first

A sibling primitive answers much of the same need and is strictly safer: read the same range in the same order and return a digest of it. Its output is thirty-two bytes whatever the input, so it needs no size ceiling and is not blocked by SAPE-31 at all.

It also answers a question asked separately — whether the store has SHA-256 — at the layer where the answer can be yes. The driver still never lets anybody write sha256(x); it lets them declare an operation that hashes a named range, exactly as it lets them declare a rollup.

For an application that splits a file into rows and wants to know the whole thing arrived intact, hashRange is the entire answer and moves nothing. This ticket recommends it as the predecessor rather than claiming it as scope: it is a separate primitive and deserves its own ticket if the owner agrees.

Layer two — how the shell offers it

An operator command beside scan, taking the same bounds. For an operator the useful form may be the one that prints the digest or the size rather than writing anything, since an operator at a prompt is usually checking rather than assembling.

Layer three — how the driver declares it

As an operation with parameters and no expression — but note the parameter count is larger than the proposal's three, because a document is not a value:

{ "action": "concatRange", "collection": "chunk",
  "input": [ {"name":"from","type":"string","required":true},
             {"name":"to","type":"string","required":true},
             {"name":"target","type":"string","required":true} ],
  "field":  "b",          // which field of each row is joined
  "into":   {"collection":"file","field":"bytes"},
  "limit":  8192,         // rows
  "maxBytes": 16777216 }

Which field is read and which field is written are part of the declaration, not the call. That keeps the shape fixed and the caller supplying bounds rather than behaviour.

The separator is nothing, and that is a decision. Joining with anything else would be the first step towards a format string, which is an expression in disguise. If a caller needs delimiters they belong in the stored values.

A row whose field is missing, or is not the declared type, must refuse. The precedent is the rollup, which refuses a document it cannot add rather than skipping it, on the stated grounds that a total which silently skips is a total nobody can trust. A concatenation with a hole in it is worse, because nothing about the result says so.

The ceiling is counted, not trusted

These three primitives are the first things in this store that walk a range at run time. Everything before them is bounded by construction: a declaration cannot express a loop, because a step pins operation@version, version numbers only ever rise, and a pinned reference resolves only to a version that already exists — so a cycle would need a version to exist before it was declared. The reference graph is a directed acyclic graph because nothing can build anything else, which is why ceiling() can compute a total at declare time and why nothing counts during a run.

That property does not survive a thing that walks. So the limit on this operation is a ceiling the host counts against while walking, not a number the host takes on trust, and it is counted per run of the operation rather than per underlying call. At the ceiling the operation stops and refuses; it does not return a shortened answer, because a truncated result is indistinguishable from a complete one.

The reason to write this down rather than assume it is measured, in pipelines/tasks/0071, measurement W7. An external operation declaring limit 50, correctly sandboxed and correctly signed, served 50,000,000 rows in one call — one million calls of a host function that served fifty each — because the host checked every individual call and never the total. A signature proves whose binary it is. A sandbox proves it does not escape. Neither proves what it costs. That gap is not open today because nothing can loop; it opens the moment something can.

Two things are counted here, rows read and bytes produced, and either one reaching its ceiling stops the operation. This is the only one of the three where the output grows with the input, which is why it is also the only one that waits on a size ceiling existing at all.

Acceptance criteria

  1. A range of rows is joined in key order and written to the target, and the result is byte-identical to joining the same values in the application — measured on a file of at least several megabytes split across thousands of rows.
  2. Exceeding either ceiling refuses by name, saying which one and what the limit was. Nothing truncated, nothing partially written.
  3. A missing or wrongly typed field in any row refuses, and the target is unchanged.
  4. The operation is atomic: the target exists complete or not at all, proved by a refusal in the middle leaving no target document.
  5. Memory does not scale with the range beyond the declared byte ceiling — the measurement this ticket exists for, since the machine it is meant for has little of it.
  6. Every new guard watched failing.

Out of scope