6.11. Streaming output with @collect
Everything so far has treated a command’s output as one value: compute it, serialize it, write it. That breaks down when the result is larger than memory, or when the caller wants to see the first rows before the last ones exist.
A streaming command returns () and hands its data out in batches. The
@collect intrinsic drives it:
@collect :: (([a] -> <IO, e> ()) -> <IO, e> ()) -> <IO, e> ()
@collect takes a producer: a function that is given a sink and calls it once
per batch. @collect supplies the sink, manages the stream, and writes each
batch out in whatever form -f selects. The element type rides on the sink, so
the compiler knows the stream’s type with no extra annotation.
sift’s `stream searches the same tree as scan but emits one batch per file:
producePy :: Str -> Str -> Options -> ([Hit] -> <IO> ()) -> <IO> ()
--' Stream hits to standard output, one file at a time
stream ::
Str ->
--' @check.path r
Str ->
Options ->
<IO> ()
stream pat root opts = @collect (producePy pat root opts)
The producer here is Python, and its last parameter is the sink:
def produce(pattern, root, opts, sink):
fold = opts["ignoreCase"]
needles = [pattern.lower() if fold else pattern]
for path in walk_files(root):
sink(hits_in(path, needles, fold))
A morloc callback crossing into a Python function is an ordinary foreign call; nothing about the streaming machinery is visible from either side.
With no action flag, every batch goes to standard output in the -f form:
$ ./sift -f jsonl stream the notes
{"path":"notes\/todo.txt","line":2,"text":"fix the parser"}
{"path":"notes\/todo.txt","line":3,"text":"write the manual"}
{"path":"notes\/2026\/plan.txt","line":1,"text":"fix the build"}
{"path":"notes\/2026\/plan.txt","line":2,"text":"ship the manual"}
6.11.1. Actions on a stream
The output actions of Output actions work here too, with one extra
dimension. On an ordinary command a formatter sees the return value; on a
streaming command it can see either the whole gathered stream or each batch as
it arrives. The @stream modifier chooses:
| Directive | Handler type | Behavior |
|---|---|---|
|
|
Gather the whole stream, apply once; |
|
|
Apply to each batch as it arrives, at constant memory; the |
|
|
Gather the whole stream, apply once, write the bytes verbatim. |
|
|
Apply to each batch, write each result’s bytes verbatim. |
|
as above, with |
Fold the stream into one accumulator, apply once to it. See
|
sift’s `stream declares one action from three of those cells:
--' Stream hits to standard output, one file at a time
--' @render -p/--plain=asLines @stream
--' @with -c/--count=countHits
--' @with -n/--staged=countStaged
--' @with -N/--numbered=numberHits(@offset) @stream
stream :: ...
-p renders each batch to text as it goes, which is the constant-memory
version of what scan -p does:
$ ./sift stream the notes -p
notes/todo.txt:2:fix the parser
notes/todo.txt:3:write the manual
notes/2026/plan.txt:1:fix the build
notes/2026/plan.txt:2:ship the manual
-c is the other extreme: gather everything and apply countHits once.
$ ./sift stream the notes -c
4
6.11.2. @offset: where a batch sits in the stream
A @stream handler is called once per batch and has no memory between calls,
so anything that depends on position has to be told. @offset supplies the
number of elements already written:
--' Number the hits as they stream past
numberHits :: U64 -> [Hit] -> [Str]
--' @with -N/--numbered=numberHits(@offset) @stream
numberHits(@offset) passes the offset as the handler’s first argument. Each
file is a separate batch, and the numbering runs across them:
$ ./sift -f jsonl stream the notes -N
"1 notes\/todo.txt:2"
"2 notes\/todo.txt:3"
"3 notes\/2026\/plan.txt:1"
"4 notes\/2026\/plan.txt:2"
@offset is only meaningful under @stream; using it elsewhere is an error.
6.11.3. IFile: the gathered stream as a file
A whole-stream handler may take its receiver as IFile [a] instead of [a].
The stream is staged to a temporary file and the handler gets a random-access
handle rather than a materialized list — the way to write a whole-stream
handler that does not need the whole stream in memory. The temporary file is
removed when the handler returns.
countStaged reads the element count out of the staged file’s footer without
touching the data:
--' Count the hits without loading them into memory
countStaged :: IFile [Hit] -> <IO> Int
countStaged f = do
Ok n <- @flen f
n
$ ./sift stream the notes -n
4
IFile and the rest of the random-access handles are covered in
Random access and streaming.
6.11.4. @fold: one accumulator instead of a list
A gather holds the whole stream before the handler sees any of it. @fold
keeps one accumulator instead, so peak memory does not grow with the stream.
It takes three terms beside the handler:
| Modifier | Type | Role |
|---|---|---|
|
|
Starts every accumulator, and is the answer for an empty stream. |
|
|
Folds one batch in. It takes the batch, not the element. |
|
|
Merges two accumulators. |
The handler receives b instead of [a]. Nothing else about the command
changes: a folding action and a gathering one sit on the same @collect, and
the flag the caller gives decides which runs.
sift streams one batch per file, so an accumulator can count both:
type Tally = (Int, Int)
noHits :: Tally
addFile :: Tally -> [Hit] -> Tally
mergeTally :: Tally -> Tally -> Tally
showTally :: Tally -> Str
--' @render -t/--tally=showTally @fold=addFile @init=noHits @combine=mergeTally
$ ./sift stream the notes -t
4 hits in 2 files
@combine is there because a producer may call its sink from several threads.
Each thread folds into an accumulator of its own, and @combine merges them at
the end. Every accumulator starts from @init, so @init has to be an identity
for @combine. Otherwise the answer changes with the number of threads the
producer used, and a single-threaded producer will not show it. Nothing checks
this — checking it would mean running your code.
What a fold costs
Peak memory is the streaming working set plus one accumulator per worker thread. Two things give that up.
Each fold copies the accumulator, so b should be of bounded size. One that
grows with the stream — collecting every element into a list — makes the fold
quadratic, a worse way to pay what the gather was already paying.
The producer must drive its sink from a worker pool rather than a thread per batch, since one accumulator exists per thread that folds. A fold that reaches a few thousand accumulators is refused rather than allowed to grow.
@combine must also be associative, and commutative if the producer is
threaded: accumulators are merged in the order the threads reached the sink,
which is not reproducible between runs.
6.11.5. Streaming rules
The rules in Rules and rejections all apply. More are specific to streaming:
-
An action applies to everything the command streams, whatever the shape of its body: a command with several `@collect`s, or one in a branch or a where-binding, gives each action the whole output, in order. Such a command’s actions run on its saved output (see Several outputs in one run), and so does any action combined with others.
-
The producer needs a signature with a concrete batch type when its
@collectis anywhere but the tail of the body. -
A
@streamhandler must return a list; its elements are what reach the wire. -
@renderunder@streamwrites each batch’s bytes as they are produced, with nothing added between batches — no separator, no trailing newline beyond what the handler itself emits. -
@foldand@streamare mutually exclusive:@streamemits one result per batch,@foldone for the whole stream. -
@foldrequires a reachable@collect, and all three of@fold,@initand@combine.
6.11.6. What the help says a stream produces
A streaming command’s Return: block describes standard output, not the
() the function returns. The two coincide for every other command and come
apart here, so the block is worth reading closely:
$ ./sift stream -h
...
Return:
default: [Hit]
-p/--plain: Str (raw bytes)
-c/--count: U64
-n/--staged: Int
-N/--numbered: [Str]
...
Five rows, five different things on stdout. default is the batch element the
sink writes; -c and -n gather and return a single value; -N transforms
each batch and streams the result; and (raw bytes) marks the one row where
-f no longer applies, because a @render action writes its handler’s bytes
verbatim.
The compiler works the element type out from the producer’s signature: a
@collect argument takes exactly one parameter, the sink, so the sink is the
last parameter of the producer’s declared type and the sink’s own parameter is
what reaches standard output. A producer with no reachable signature — an
inline lambda — leaves the row falling back to the return type; the help never
claims () for a command that streams.