Skip to content

Recipe: stream a file of records

When a file is a long sequence of independent records — log lines, NDJSON, sensor readings, the top-level definitions of a source file — and is too large to hold in memory, you can read, cut, and parse it as one bounded-memory pipeline: peak memory is about one record plus the parses in flight, flat in the file size. This recipe wires a streaming chunker to walk_parallel and walks through the details that bite — the preamble, a whitespace terminator, absolute positions, and metadata the parse tree can't see.

The running example: a file whose records are one per line, each parsed by a grammar rule record (which ends in a mandatory EOL), generated as RecordEventListener.

The pipeline

Split on the newline with where="after" so each chunk ends with its terminator, and feed the lazy stream straight to walk_parallel:

from record_listener import RecordEventListener as R

chunks = R.stream_on_pattern("big.log", r"\n", where="after", trim=False)
for listener in R.walk_parallel(chunks, start_rule="record"):
    ...  # one fully-walked listener per record, in input order

Both ends pull lazily: stream_on_pattern reads the file over a sliding window and materializes one chunk at a time; walk_parallel keeps a bounded number of parses in flight. Nothing holds the whole file.

Keep the terminator: trim=False

By default every chunker trims surrounding whitespace from each chunk and drops whitespace-only regions. That is not what we want in this case: a record rule that ends in EOL needs the trailing newline, and the default would strip it, so the parse fails on a missing EOL. Pass trim=False to keep each region verbatim (only truly empty, zero-length regions are dropped):

# trim=True (default):  '{"a": 1}'      -> EOL gone, record won't match
# trim=False:           '{"a": 1}\n'    -> terminator preserved

trim=False is available on every delimiter chunker (split_on_token, stream_on_token, split_on_pattern, stream_on_pattern, …).

The preamble (content before the first delimiter)

With where="before", the region before the first delimiter becomes its own leading chunk — a header or preamble; with where="after", the region after the last delimiter is a trailing chunk. So a file that opens with a header line and then records, cut on a record marker, yields the header as the first chunk:

stream = R.stream_on_pattern("big.log", r"^record\b", flags=re.MULTILINE, where="before")
header = next(stream)                                   # the pre-first-record preamble
for listener in R.walk_parallel(stream, start_rule="record"):
    ...                                                 # stream now yields only records

If the file may or may not have a preamble, don't assume the first chunk is one — recognize it (a real record chunk starts with the marker; the preamble does not), or choose a where/pattern that excludes it. The same leading/trailing region appears with the in-memory split_* chunkers; see Chunking.

Absolute positions

Each Chunk pins its offset / line / column against the whole file, not the chunk, and walk_parallel carries that through. So inside a callback, span and line_col report whole-file positions, and sourcename (defaulting to the file path) lets you log sourcename:line:column — exactly as if you had parsed the file in one piece.

Recover off-channel metadata

The walk is channel-blind: walk_parallel walks the parse tree, which contains only the tokens the parser consumed — the default channel. Tokens the lexer routed to a hidden channel (comments, directives, alignment metadata) are never in the tree, so they never reach visitTerminal. To recover them, lex the chunk text yourself — lex returns every token with its channel — and keep the off-channel ones. Because the chunk text is in hand only while you hold the chunk, do it in an inline loop rather than the pure walk_parallel form:

for chunk in R.stream_on_pattern("big.log", r"\n", where="after", trim=False):
    listener = R().walk(chunk.text, start_rule="record")        # on-channel structure
    comments = [t for t in R.lex(chunk.text) if t.channel != 0]  # 0 = default channel
    # a LexToken's start/stop are offsets *within the chunk*; add chunk.offset
    # for whole-file positions:
    spans = [(chunk.offset + t.start, chunk.offset + t.stop) for t in comments]

This trades the thread pool for the chunk text; to keep both, lex inside a wrapper generator that yields (chunk, comments) and parse the chunks in parallel separately.1

When records are not delimiter-marked

If records are defined by grammar structure rather than a delimiter — and the file is a directly-adjacent top-level sequence of them — use stream_by_rule, which parses one record at a time and needs no delimiter. It requires the whole input to be that sequence (only lexer-skipped whitespace/comments may separate records) and raises a clear error if an on-channel token begins no candidate rule — an unsupported header or separator results in an error, not a silently truncated stream. For comma-separated or otherwise on-channel-delimited records, stay with stream_on_token / stream_on_pattern.

Cost of a full per-record listener

A full Python listener pays one loop iteration per kept event, so a record walked with everything subscribed costs roughly its native parse again on the Python side. Two levers claw it back, both covered in Performance: subscribe to only the rules/tokens you need (the facade emits just those from C++), or aggregate in a rule so fewer events cross into Python at all.


  1. The need for a first-class channel-aware callback during the walk is tracked in issue #2.)