New to Kestra?
Use blueprints to kickstart your first workflows.
Audit source-to-destination row-count parity using one shared Malloy model, classify drift, and alert Slack on breach.
An ETL or replication pipeline that silently drops rows, duplicates them, or stalls partway through usually shows up first as a row-count mismatch between the source system and its downstream copy, and usually nobody notices until a report looks wrong. This blueprint uses plugin-malloy's CLI task to run one reusable Malloy semantic model, the same aggregate: row_count is count() definition, against both sides of a pipeline, so the parity check is expressed once instead of as two hand-written, slowly-diverging SQL queries.
query_source_row_count (io.kestra.plugin.malloy.CLI) writes a small Malloy model that opens source_table_uri via duckdb.table(...) and runs a single count() aggregate, redirects the result to results.json, and emits the parsed count through Kestra's ::{"outputs":...}:: structured-output marker so it lands in vars. allowFailure: true lets the flow continue instead of aborting on a compile error or unreachable URI.query_destination_row_count runs the identical model shape against destination_table_uri.classify_parity_status (io.kestra.plugin.core.debug.Return) checks whether both vars.row_count values are defined. If either is missing, the classification is PROBE_FAILED. If both are present and within max_row_count_drift of each other in both directions, the classification is PARITY_OK; otherwise PARITY_DRIFT_DETECTED.route_by_parity_status (io.kestra.plugin.core.flow.Switch) branches on that string. PARITY_DRIFT_DETECTED runs evaluate_alert_toggle (io.kestra.plugin.core.flow.If), which pages Slack via dispatch_parity_drift_alert only when send_slack_alert is true, logging a suppressed-alert line otherwise. PARITY_OK logs a clean audit line. PROBE_FAILED pages Slack directly since parity is now unknown. The defaults branch logs a fallback line that is unreachable under normal operation.log_audit_summary records a status line on every run regardless of branch, so execution history reads as a continuous parity trend.errors block pages Slack separately if the sentinel flow itself fails outright.parity_audit_schedule trigger polls every 6 hours with explicit input overrides, shipped disabled: true until pointed at a real source and destination.status output (PARITY_OK / PARITY_DRIFT_DETECTED / PROBE_FAILED) other flows can gate on before trusting a downstream report or dataset.A one-off SELECT COUNT(*) run by hand on each side proves parity for one instant and leaves no record, and it has to be rewritten per database dialect. This flow keeps every audit in Kestra's execution history, turns the result into a typed status output other flows can depend on, and gives you an If/Switch branch to grow into real remediation (re-triggering the replication job, opening a ticket) without rewriting the comparison logic.
source_table_uri and destination_table_uri reachable by DuckDB's duckdb.table() function from inside the Malloy runner container (local file paths mounted into the container, or an HTTP(S) URL as in the default).jq available inside malloy_container_image to parse results.json; this blueprint has not verified whether the default ghcr.io/kestra-io/malloy image ships jq, see Pitfalls.SLACK_WEBHOOK secret.status reads PARITY_OK.destination_table_uri with your real replicated dataset to audit actual parity.parity_audit_schedule.row_count to catch value-level drift, not just row-count drift; parse the additional fields from results.json the same way.Loop task, reusing the same model shape with a different table URI per iteration.status into a KV store to alert only on state transitions instead of every run.malloy-cli version's JSON output does not match the .[0].row_count shape this blueprint assumes, adjust the jq filter to match your actual output structure before relying on this in production.SLACK_WEBHOOK: Slack incoming webhook used for the drift, probe-failure, and flow-failure alerts.SECRET_SLACK_WEBHOOK as base64, for example echo -n "<your-webhook-url>" | base64.source_table_uri (STRING, default the plugin's own iris.csv demo dataset): source-of-truth dataset URI.destination_table_uri (STRING, default same demo dataset): replicated/destination dataset URI.max_row_count_drift (INT, default 0): maximum allowed absolute row-count difference before classifying as drifted.malloy_container_image (STRING, default ghcr.io/kestra-io/malloy): container image running malloy-cli.send_slack_alert (STRING, default "true"): when "true", page Slack on drift; when "false", only log it.{{ outputs.query_source_row_count.vars.row_count }}: parsed row count from the source dataset.{{ outputs.query_destination_row_count.vars.row_count }}: parsed row count from the destination dataset.{{ outputs.query_source_row_count.outputFiles['results.json'] }}: internal storage URI of the raw source query result.{{ outputs.classify_parity_status.value }}: classification string, one of PARITY_OK, PARITY_DRIFT_DETECTED, PROBE_FAILED.{{ outputs.status }}: flow-level alias of the same classification.malloy-cli run model.malloy writes a JSON array of row objects (for example [{"row_count": 150}]), inferred from plugin-malloy's own official example naming its redirected output results.json and from Malloy's query syntax, where row_count is count() names the output field directly. This has not been executed and confirmed against a running malloy-cli; verify the actual shape against your installed version before trusting it in production, and adjust the jq '.[0].row_count' filter if it differs.jq must be present in malloy_container_image; if the default ghcr.io/kestra-io/malloy image lacks it, add a beforeCommands step to install it, or replace the jq line with a different parser.results.json, inside their own isolated working directories; they do not share state or overwrite each other, but do not assume a single shared results.json across both tasks if you refactor this into one task.duckdb.table() needs outbound network access from the container when pointed at an HTTP(S) URL, and a correctly mounted path when pointed at a local file; a reachability failure surfaces as PROBE_FAILED, not PARITY_DRIFT_DETECTED.This flow needs no separate database container: both Malloy tasks pull and run ghcr.io/kestra-io/malloy directly via plugin-malloy's default Docker task runner, and DuckDB executes in-process against the given URIs. To confirm the image works locally before running the flow, execute:
docker run --rm ghcr.io/kestra-io/malloy malloy-cli --help
If Kestra's Worker is running inside a Docker network and you point either URI at a container-hosted file server, connect that container to Kestra's network:
docker network connect YOUR_KESTRA_NETWORK
(Run docker network ls to verify your active Kestra network name.)
