Restructuring rows — reshape & cull
The question for this lesson: the last two lessons brought rows together: folding many
into a summary (aggregate), joining two streams (combine), stacking peers (merge). But
sometimes you don’t want to combine or summarize rows at all. You want to rewrite some of
them (split one over-long record into two), or drop or route out whole groups of them.
How do you do that?
Two node types handle this, and they work within a group, not across streams:
reshaperewrites rows by rules inside a partition (e.g. close one record at a boundary and synthesize a continuation row).culldrops or side-routes whole groups by a group predicate (e.g. send every employee with more than three plan rows to a review output).
What you’ll be able to do
Section titled “What you’ll be able to do”- Use a
reshapenode to rewrite rows by rules within a partition (partition_by,order_by,rules,when,mutate.set,synthesize). - Use a
cullnode to drop or side-route rows by a group predicate, and read the removed rows off itsremoved_toside-port. - Explain that
reshapeis blocking and grouped (it sees a partition’s whole group before any rule fires, and its rules don’t cascade), and thatcull’s removed rows go to a side-port, not silently away.
Reshape: rewrite rows by rules within a partition
Section titled “Reshape: rewrite rows by rules within a partition”A reshape node
takes one input and rewrites rows according to rules, working within a partition (a
group
of related rows). This is Clinker’s real employee_plan_backfill reshape. Within each
employee’s plan history, a coverage record that runs longer than a fiscal year is an un-split
record, so reshape closes it at the start boundary and synthesizes a continuation:
- type: reshape name: backfill input: plans config: partition_by: [employee_id] # the group: one employee's plan history order_by: - { field: plan_start, order: asc } # sort within the group, oldest first rules: - name: split_long_plan when: "plan_start - plan_end > 365" # which rows this rule fires on (a CXL predicate) mutate: set: # Close the over-long window at its start boundary. plan_end: "plan_start" # rewrite a field on the triggering row synthesize: copy_from: trigger # start the new row as a copy of the trigger row overrides: status: "'synthesized'" # mark the continuation row🌱 New here? — partition_by and order_by
reshape doesn’t look at one row at a time. partition_by: [employee_id] collects all the
rows for an employee into one group, and order_by sorts that group (here, by
plan_start, ascending). Only then do the rules run, so a rule can reason about a row’s place
within its group, not just the row on its own.
The pieces of a rule:
when:is a CXL predicate that decides which rows the rule fires on. Here,plan_start - plan_end > 365picks the over-long records and leaves the rest untouched.mutate.set:rewrites fields on the triggering row.plan_end: "plan_start"closes the window at its start boundary. The value on the right is a CXL expression.synthesize:optionally emits an extra row alongside the mutated one.copy_from: triggerstarts the new row as a copy of the row that fired, thenoverrides:change specific fields (here,status: "'synthesized'", where the inner quotes make it the literal stringsynthesized, not a field reference). Thescd_type2example usescopy_from: noneinstead, building the continuation row field by field.
Three idiosyncrasies make reshape behave unlike a plain transform:
- It is blocking and grouped. Like an
aggregate, it holds rows back: it observes a partition’s whole group before any rule fires, so a rule can see the full picture for that employee. - Rules don’t cascade. A rule’s output (the mutated row, the synthesized row) is not fed back through the rules. Each rule sees the original group, so you never get a chain reaction where one rule’s change triggers another.
- It can spill to disk under a memory budget. Because it buffers each group,
reshapegoverns that buffer against the engine’s memory budget. Thescd_type2example setspipeline.memory: { limit: "16K", backpressure: spill }to force the spill path even on a tiny fixture; the result is identical whether a group stayed in memory or round-tripped through disk.
Cull: drop or side-route whole groups by a group predicate
Section titled “Cull: drop or side-route whole groups by a group predicate”A cull node also works within a partition, but instead of rewriting rows it removes whole
groups, and the removed rows are not thrown away. This is the employee_plan_backfill
cull: after backfill, any employee with more than three plan rows is routed to a manual-review
output:
- type: cull name: flag_large_histories input: backfill config: partition_by: [employee_id] # group the rows by employee removed_to: review # name the SIDE-OUTPUT port for removed rows rules: - name: too_many_plans drop_group_when: "count(*) > 3" # a GROUP-level predicate over the partitionA downstream node reads the removed rows by referencing the side-port,
flag_large_histories.review:
# Main output: employees with a manageable plan history.- type: output name: out input: flag_large_histories # the rows that were KEPT
# Side output: employees flagged for manual review.- type: output name: review input: flag_large_histories.review # the rows that were REMOVEDThe pieces:
partition_by:is the same asreshape: it groups the rows so the predicate can be evaluated over a whole group, not one row.drop_group_when:is a group-level predicate.count(*) > 3counts the rows in the partition; if the whole group satisfies it, the entire group is removed from the main stream.removed_to:names a side-output port. Removed rows are valid records deliberately partitioned onto a second stream; they go to<cull_name>.<removed_to>, where a downstream node can pick them up. This is not the dead-letter queue (that’s for errors, lesson 07). These are good rows, just sent down a different road.
🌱 New here? — a group-level predicate
drop_group_when: is evaluated over the group, using the same group aggregates you met with
aggregate in lesson 05: count, sum, min, max, avg, collect. So
count(*) > 3 means “this partition has more than three rows.” There is no bare any()
aggregate, so “any row in the group matches X” must be written as a count, e.g.
sum(if <cond> then 1 else 0) > 0.