Skip to content

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:

  • reshape rewrites rows by rules inside a partition (e.g. close one record at a boundary and synthesize a continuation row).
  • cull drops or side-routes whole groups by a group predicate (e.g. send every employee with more than three plan rows to a review output).
  • Use a reshape node to rewrite rows by rules within a partition (partition_by, order_by, rules, when, mutate.set, synthesize).
  • Use a cull node to drop or side-route rows by a group predicate, and read the removed rows off its removed_to side-port.
  • Explain that reshape is blocking and grouped (it sees a partition’s whole group before any rule fires, and its rules don’t cascade), and that cull’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 > 365 picks 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: trigger starts the new row as a copy of the row that fired, then overrides: change specific fields (here, status: "'synthesized'", where the inner quotes make it the literal string synthesized, not a field reference). The scd_type2 example uses copy_from: none instead, 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, reshape governs that buffer against the engine’s memory budget. The scd_type2 example sets pipeline.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 partition

A 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 REMOVED

The pieces:

  • partition_by: is the same as reshape: 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(*) > 3 counts 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.