Schedule icon
CLI icon
Return icon
Switch icon
If icon
SlackIncomingWebhook icon
Log icon

Malloy Semantic Row-Count Parity Gate

Audit source-to-destination row-count parity using one shared Malloy model, classify drift, and alert Slack on breach.

Categories
Data

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.

How it works

  1. 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.
  2. query_destination_row_count runs the identical model shape against destination_table_uri.
  3. 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.
  4. 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.
  5. log_audit_summary records a status line on every run regardless of branch, so execution history reads as a continuous parity trend.
  6. The flow-level errors block pages Slack separately if the sentinel flow itself fails outright.
  7. The parity_audit_schedule trigger polls every 6 hours with explicit input overrides, shipped disabled: true until pointed at a real source and destination.

What you get

  • A scheduled row-count parity check expressed as one reusable Malloy model instead of two independently maintained SQL queries.
  • A three-way status output (PARITY_OK / PARITY_DRIFT_DETECTED / PROBE_FAILED) other flows can gate on before trusting a downstream report or dataset.
  • A Slack page naming both row counts, both URIs, and the configured tolerance on breach or probe failure.
  • A separate failure channel for the sentinel flow itself, plus a standing per-run audit log line.

Who it's for

  • Data engineers running ETL or replication pipelines who need an early signal that the destination silently diverged from the source.
  • Analytics engineering teams standardizing on Malloy as a semantic layer who want the same model reused for monitoring, not just for dashboards.
  • Platform teams building Day 2 data-governance checks that compare two systems without hand-duplicating query logic per backend.

Why orchestrate this with Kestra

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.

Prerequisites

  • Both 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.
  • A Slack incoming webhook for the drift, probe-failure, and flow-failure alerts.

Quick start

  1. Add the SLACK_WEBHOOK secret.
  2. Run the flow once with the shipped defaults, both URIs point at the same public demo dataset, and confirm status reads PARITY_OK.
  3. Replace destination_table_uri with your real replicated dataset to audit actual parity.
  4. Enable parity_audit_schedule.

How to extend

  • Extend the Malloy model with additional aggregates (sums, distinct counts) beyond row_count to catch value-level drift, not just row-count drift; parse the additional fields from results.json the same way.
  • Fan this out across every table in a replication pipeline with a Loop task, reusing the same model shape with a different table URI per iteration.
  • Push status into a KV store to alert only on state transitions instead of every run.
  • If your installed 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.

Secrets

  • SLACK_WEBHOOK: Slack incoming webhook used for the drift, probe-failure, and flow-failure alerts.
  • OSS note: if your Kestra instance reads secrets from environment variables instead of a secrets backend, set SECRET_SLACK_WEBHOOK as base64, for example echo -n "<your-webhook-url>" | base64.

Inputs

  • 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

  • {{ 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.

Pitfalls

  • This blueprint assumes 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.
  • Row-count parity alone does not catch row-level corruption, duplicate rows that cancel out, or column-level drift; it only catches count mismatches. Extend the model's aggregates for deeper coverage.
  • Both tasks write to the same filename, 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.

Local Container Setup

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.)

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.