Provenance#
Linking recorded rows across tables through the __provenance__ graph of call and used_by edges.
Understand call and used_by edges#
The __provenance__ table links recorded rows with directed call and used_by edges, so a query can trace what produced what.
APIs: knit_with_setup
Each command records its own rows in its own table (see Record invocations in a
table). The provenance graph is what links those rows across tables: a
single __provenance__ table whose every row is one directed edge between two
invocations, written as source --edge_type--> target. An edge stores the
(id, name) pair of its source and target — the id joins to a row, and
the name identifies which table that row lives in — so a query can hop from
a row in one table to a related row in another.
There are two kinds of edge:
call — the source invoked the target. Every time a participating command runs another command, knit records a
calledge from caller to callee, and stamps it with the call’sstart_timeandend_time. This is how a job’s row links to theknit runrows it launched, and those to the app rows the run recorded. Acalledge may also carry analiasnaming the call site (see Distinguish repeated calls).used_by — the target references a setup that an earlier invocation built. When a job (or other command) declares
knit_with_setup(see Depend on a setup), knit records aused_byedge from the setup (the source, the antecedent) to the invocation that uses it (the target). It has no duration, so its timestamps areNULL.
The direction is consistent: the source is always the antecedent (the caller, or
the setup that was built first) and the target the dependent (the callee, or the
consumer). Which invocations become nodes is governed by participation — see
Opt in/out of the provenance graph — and the edges themselves are queried
with knit query graph.
Opt in/out of the provenance graph#
Force a command in or out of the provenance graph with knit_with_provenance and knit_without_provenance.
APIs: knit_with_provenance, knit_without_provenance
Alongside data rows, knit records a provenance graph: each participating invocation is a node, and calling one command from another records a “call” edge, so a query can trace which run produced which result. Whether a command participates is orthogonal to whether it records a table (see Record invocations in a table) — a command can do either, both, or neither.
By default participation follows visibility: visible commands participate; hidden
commands (knit_hidden, e.g. internal workers) are transparent. Override that
default explicitly.
Use knit_without_provenance to keep a visible command out of the graph. A
read-only fan-in command that only reads back existing rows is the typical case
— it should not add call edges of its own:
@command "aggregate" \
"Fan-in: total the inside metric across every recorded render."
@without_provenance
_aggregate() {
# Read back what the renders recorded. Each `knit run -- render` wrote one row
# in the `render` table (rank 0 only), so one SELECT sees every image the
# experiment has produced, across every job submission.
local count total
count=$(knit query sql --exec "SELECT count(*) FROM render;")
total=$(knit query sql --exec "SELECT sum(inside) FROM render;")
printf 'Summed inside=%s over %s render(s).\n' "${total:-0}" "${count:-0}"
}
@done
knit_register "aggregate" _aggregate \
"Fan-in: total the inside metric across every recorded render."
knit_without_provenance
_aggregate() {
# Read back what the renders recorded. Each `knit run -- render` wrote one row
# in the `render` table (rank 0 only), so one SELECT sees every image the
# experiment has produced, across every job submission.
local count total
count=$(knit query sql --exec "SELECT count(*) FROM render;")
total=$(knit query sql --exec "SELECT sum(inside) FROM render;")
printf 'Summed inside=%s over %s render(s).\n' "${total:-0}" "${count:-0}"
}
knit_done
Use knit_with_provenance for the opposite: force a command into the graph
even when it would otherwise be transparent (for example a hidden command you
still want to see as a node).
Either mark propagates to unmarked lexical descendants, so marking a parent command governs its whole colon-nested subtree unless a child overrides it. A transparent command is skipped when a command it invokes resolves its parent: the callee links to the nearest participating ancestor instead.
Distinguish repeated calls#
Name a call with knit_as so repeated invocations of the same command can be told apart in a provenance query.
APIs: knit_as
When one command invokes another more than once, each call is a separate edge in
the provenance graph — but by default the edges look alike, so a later query
cannot tell one call apart from another. knit_as names a single call: it
records an alias on that call’s provenance edge, then runs the command:
knit_as fast run --procs 8 -- mcrank
knit_as slow run --procs 1 -- mcrank
Used as knit_as <alias> <cmd> …, it is equivalent to knit <cmd> … except
that the delegated invocation’s call edge carries <alias>. A query can then
address each call independently — “the fast run” versus “the slow run”
— instead of seeing two indistinguishable edges. Without knit_as a call
edge’s alias is NULL.
The alias is one-shot: it lands only on the directly named call’s edge, never on the nested edges that call’s own body records. It is validated at the call site — it must be non-empty, must not be a registered table name (which would collide with a node label in a query), and must not already have been used within the current invocation (two edges sharing an alias would be indistinguishable).
Consume an artifact by kind#
Require a produced artifact of a given kind with knit_with_input_artifact, resolve it with knit_input_artifact_path, and record a used_by edge.
APIs: knit_with_input_artifact, knit_input_artifact_path
A command consumes an artifact another command produced by declaring
knit_with_input_artifact "name:kind" "description". This registers a required
string parameter whose value is the artifacts-relative path of a recorded
artifact of that kind. In the body, knit_input_artifact_path resolves that
path to the on-disk file:
# A consumer requires an artifact of a given kind. The parameter value is the
# artifacts-relative path of a recorded artifact; Knit resolves it, refuses a
# missing artifact or a kind mismatch, and --- with --verify-checksum ---
# re-hashes the bytes and refuses a table that changed since it was produced.
@command "summarize" "Consume a csvfile artifact and count its data rows."
@with_input_artifact "table:csvfile" "Artifacts-relative path of the CSV to read." --verify-checksum
@with_output "rows:integer" "0" "Number of data rows in the consumed table." --result
@with_table
_summarize() {
# Resolve the recorded artifacts-relative path to the on-disk file.
local csv rows
csv="$(knit_input_artifact_path "$(knit_get_parameter table "$@")")"
rows=$(( $(wc -l < "${csv}") - 1 )) # one header line; the rest are data
knit_output "rows" "${rows}"
printf 'summarize: %s data rows\n' "${rows}"
}
@done
# A consumer requires an artifact of a given kind. The parameter value is the
# artifacts-relative path of a recorded artifact; Knit resolves it, refuses a
# missing artifact or a kind mismatch, and --- with --verify-checksum ---
# re-hashes the bytes and refuses a table that changed since it was produced.
knit_register "summarize" _summarize "Consume a csvfile artifact and count its data rows."
knit_with_input_artifact "table:csvfile" "Artifacts-relative path of the CSV to read." --verify-checksum
knit_with_output "rows:integer" "0" "Number of data rows in the consumed table." --result
knit_with_table
_summarize() {
# Resolve the recorded artifacts-relative path to the on-disk file.
local csv rows
csv="$(knit_input_artifact_path "$(knit_get_parameter table "$@")")"
rows=$(( $(wc -l < "${csv}") - 1 )) # one header line; the rest are data
knit_output "rows" "${rows}"
printf 'summarize: %s data rows\n' "${rows}"
}
knit_done
Before the body runs, Knit resolves the path to its artifacts row and refuses
the run when the value is empty, when no artifact is recorded at that path, or
when the recorded artifact’s kind is not the required one — so the body never
sees the wrong kind of input. Add --verify-checksum (as here) to also re-hash
the bytes and refuse a run when the artifact changed since it was produced; the
digest recorded at production time is the reference. The kind must be registered
(builtin or via Declare an artifact kind).
Consuming an artifact records a used_by provenance edge from the artifact’s
row to the consuming invocation, the mirror of the produced edge the
producer left. Together they complete the lineage producer --produced--> artifact
--used_by--> consumer, which a single query walks end to end (see Walk an
artifact’s full lineage). A plain file input that is not a recorded artifact
takes a bare path parameter instead and leaves no edge — reach for
knit_with_input_artifact only when the input is a tracked artifact whose
lineage you want.
Discover and merge a fan-out#
Pair a *-output producer with a +/ -input consumer so one command scatters many artifacts and the next gathers them by glob.*
APIs: knit_with_output_artifact, knit_artifact, knit_with_input_artifact, knit_input_artifact_paths
Variadic artifacts compose into a discover-and-merge pattern across two
commands: a producer fans out a * collection, and a consumer gathers the
whole set through a + input with one glob argument — without either side
hard-coding how many members there are. The producer binds one collection name
per member:
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
@command "shard" "Fan out a range into several CSV shards."
@with_optional "n:integer" "3" "How many shards to write."
@with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
@with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
@done
# A "*" (zero or more) output is a COLLECTION: the body may bind the same name
# any number of times, and each binding is its own artifacts row with its own
# "produced" edge. (Use "+" for one-or-more, which is fatal if the body binds
# nothing.) Here one run scatters `n` CSV shards under the artifacts root.
knit_register "shard" _shard "Fan out a range into several CSV shards."
knit_with_optional "n:integer" "3" "How many shards to write."
knit_with_output_artifact "shards:csvfile*" "The CSV shards (zero or more)."
knit_with_table
_shard() {
local n out i
n="$(knit_get_parameter "n" "$@")"
out="$(knit_artifact_dir)"
mkdir -p "${out}"
for (( i = 1; i <= n; i++ )); do
printf 'id,sq\n%d,%d\n' "${i}" "$(( i * i ))" > "${out}/shard-${i}.csv"
knit_artifact "shards" "shard-${i}.csv" # bind one member of the collection
done
printf 'wrote %s shard(s)\n' "${n}"
}
knit_done
The consumer discovers them with a glob and merges what it finds:
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
@command "merge" "Merge every shard matched by a glob into one row count."
@with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
@with_output "rows:integer" "0" "Total data rows across the merged shards." --result
@with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
@done
# A "+" (one or more) input requires at least one member. Its argument is a
# comma-separated list of artifacts-relative paths, and any element holding a
# glob metacharacter (*, ?, [) is expanded against the artifacts root --- so one
# `--shards 'shard-*.csv'` gathers the whole fan-out. knit_input_artifact_paths
# fills a bash array with the resolved on-disk paths, in order, de-duplicated.
knit_register "merge" _merge "Merge every shard matched by a glob into one row count."
knit_with_input_artifact "shards:csvfile+" "Artifacts-relative glob of the shards to merge (one or more)."
knit_with_output "rows:integer" "0" "Total data rows across the merged shards." --result
knit_with_table
_merge() {
local -a paths=()
knit_input_artifact_paths paths "$(knit_get_parameter "shards" "$@")"
local total=0 p
for p in "${paths[@]}"; do
total=$(( total + $(wc -l < "${p}") - 1 )) # drop each shard's header line
done
knit_output "rows" "${total}"
printf 'merged %s shard(s), %s data row(s)\n' "${#paths[@]}" "${total}"
}
knit_done
Run them in sequence — shard --n 3 then merge --shards 'shard-*.csv'
— and the glob resolves to whatever the fan-out produced. The provenance keeps
each member distinct on both sides: the producer leaves one produced edge per
bound member, and the consume leaves one used_by edge per resolved member, so
the lineage producer --produced--> member --used_by--> consumer holds for
every file in the set. One join over the two edge kinds recovers the whole group,
and what merged this shard? or which shards fed this merge? are the two ends
of that same walk (see Walk an artifact’s full lineage). This scales a sweep
without a fixed member count: add more shards and the same glob gathers them.