Skip to content

feat(databricks): data quality monitoring from a contract's freshness and completeness SLOs - #864

Merged
fas89 merged 7 commits into
mainfrom
feat/databricks-data-quality-monitoring
Nov 12, 2026
Merged

fas89 merged 7 commits into
mainfrom
feat/databricks-data-quality-monitoring

Conversation

@fas89

@fas89 fas89 commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

What

A Databricks-bound expose that states a freshness or completeness SLO now turns on Databricks data quality monitoring (formerly anomaly detection) for the schema the product owns, so Catalog Explorer shows a Quality indicator on its table and every scan lands in system.data_quality_monitoring.table_results.

  • Asks for it (no new contract field): qos.freshnessSLO, qos.completenessTarget, an observability.defaultSLIs freshness / completeness block, or a contract.dq.rules entry of type freshness, completeness or anomaly_detection. contract.dq.monitoring.enabled: false opts an expose out.
  • Emits one databricks_data_quality_monitor per owned schema (object_type = "schema", object_id = databricks_schema.<key>.schema_id), depends_on the schema's tables so the first scan finds them, with the product's other tables there in anomaly_detection_config.excluded_table_full_names. New module fluid_build/iac/providers/databricks_quality.py.
  • Thresholds: the feature takes none. On provider 1.140.0, excluded_table_full_names is its only setting, and the job parameters that took threshold overrides are closed to new customers since 2025-07-21. So the SLOs choose which tables are watched; their values are not the bounds Databricks checks. fluid apply says so in a notice, and fluid validate warns about a dq.monitoring.window other than one day.
  • Brownfield: a monitor turned on outside forge-cli is adopted, not created over (a second create is 400 RESOURCE_ALREADY_EXISTS). Its import id schema,<schema_id> is a UUID Databricks assigns, so ImportBlock gains id_from: the engine completes the id from state after the schema's own import, and skips it while the schema is not there. import_section never renders such a block.
  • Destroy / removal: the monitor is deleted before its schema (its object_id references schema_id). Removing it (opt-out, SLOs dropped, expose removed) deletes its hidden scan job and what it learned, never table data, so databricks_data_quality_monitor joins RUN_DEFINITION_RESOURCE_TYPES: not gated, printed.
  • Refused monitor: if Databricks refuses it (no serverless compute, no MANAGE on the schema), tofu apply fails after the tables exist. A new optional plugin hook apply_failure_hints(address, detail) (documented in iac/base.py) adds what it needs and the opt-out to the suggestions (after CLIError's three generic ones, ahead of the tofu state list hint); the event stays opentofu_apply_failed.

Cost (Free Edition)

Scans run on serverless compute billed as DATA_QUALITY_MONITORING, once when monitoring is turned on and then as often as each table is updated. Free Edition runs the feature; expect the scans to count against its daily compute quota (not measured): past the quota, all compute stops for the rest of the day. The demo's 19 tower products all state SLOs, so their next apply turns on 19 monitors. The CHANGELOG says to opt out what need not be watched. The DBU cost was not measurable in-session: system.billing.usage lagged the scans by hours.

Borrowed, not built

  • The provider's own databricks_data_quality_monitor resource (present in the pinned 1.140.0; schema dumped from the binary): no REST code, no pin bump.
  • Prior art searched: DABs data_quality_monitor (databricks/cli), laktory's generated model, datacontract-cli service levels (no Databricks monitor emission), Elementary (freshness_anomalies beside data_freshness_sla: learned and stated thresholds are complements, the pattern followed here). No repo drives this resource from a data contract.

Validation

  • Lint battery (CI-exact): ruff check fluid_build/ tests/, the isolated dead-code sweep, black==24.10.0 --check, license headers, lint-imports (6 kept), product guardrail, detect-secrets-hook on the changed files: clean.
  • New tests: tests/iac/test_iac_databricks_quality.py (37 unit), tests/iac/test_iac_databricks_quality_fake_e2e.py (6, real fluid + tofu + provider 1.140.0 against the stand-in, which now serves the data-quality endpoints shaped on the live answers), test_iac_tofu_validate.py::test_a_databricks_data_quality_monitor_passes_tofu_validate.
  • Full suite, CI-style (-n 6 --dist loadscope --ignore=tests/perf, tofu off PATH, --randomly-seed=20261112): 34824 passed, 9 failed, 2 errors on the tip before the last rebase. Every failure is this machine's shared-venv baseline, identical on main (lazy-core x2, dlt dotenv x4, duckdb sandbox x2 on macOS), plus one known xdist flake (TestSnowflakeLogin, passes alone); the 2 errors are bashlex missing from the shared venv. After rebasing onto feat(databricks): Unity Catalog primary and foreign keys from the contract #860 and fix(local): no plan-validation failure for an expose an acquisition build lands #862, tests/iac + tests/cli with real tofu: 6885 passed, 2 failed (the lazy-core baseline).
  • ripwire --quality-delta (branch diff on a pristine base): remaining rows are churn of the touched symbols, test-idiom clones and the stand-in class's size; planned_objects' growth was extracted into _monitor_actions.
  • Live, Databricks Free Edition, 2026-11-12 (scratch catalog fluid_iactest_dqm, DROP ... CASCADE after, 0 left; never touched workspace or northwind_towers):
    • POST /api/data-quality/v1/monitors on Free Edition: 200, a hidden anomaly-detection workflow, first run SUCCESS after ~8 min.
    • fluid apply of a two-table product with the tower pack's smart-meter SLOs: +14, second plan +0 ~0 -0.
    • system.data_quality_monitoring.table_results: readings → Training (freshness/completeness Unknown, RECENTLY_CREATED); no row for the excluded notes.
    • Catalog Explorer: Quality: Training on readings; Quality: Excluded (Reason: User Configured) on notes.
    • Adoption of a REST-made monitor: adopted 2 pre-existing resource(s), then an empty plan.
    • Last exclusion leaving: ~1 (provider PATCHes update_mask=anomaly_detection_config,data_profiling_config with an empty config), the list is gone live, next plan empty.
    • Opt-out: -1, printed as not gated, monitor GET 404 RESOURCE_DOES_NOT_EXIST.

Reviews (/ship)

  • /security-review: no findings (Opus), re-run after the fix wave: no findings.
  • /world-class-inspection (Diff mode): CONCERNS → one P2 (exclusion list shrinking to empty unproven against the real API) resolved by the live measurement plus a stand-in that honours update_mask and a pinning test; the refused-monitor error path got its fix-it. Re-check: all fixed, no new defects.
  • /defect-scan (Diff mode): no findings; three LOW UX wording items fixed (Catalog Explorer's "Quality" label, the opt-out named in the notice, a next step in the window warning).
  • Rounds: 1.

Follow-ups

  • forge_docs: the Databricks provider page needs a "Data quality monitoring" section (docs live in Agenticstiger/forge_docs).
  • Alerts: provider 1.140.0's anomaly_detection_config has no notification_settings (its only one is data_profiling_config's on_failure); re-check a newer provider before mapping observability.onBreach (wording corrected in docs(databricks): correct three claims about data quality monitoring #866).
  • Record the serverless DBU cost once system.billing.usage catches up.
  • Pre-existing, untouched: an unused authz local in databricks.py::_refuse_governance.

Documentation

… and completeness SLOs

An expose bound to Databricks that states a freshness or completeness SLO
(qos.freshnessSLO, qos.completenessTarget, an observability.defaultSLIs
freshness or completeness block, or a contract.dq.rules entry of type
freshness, completeness or anomaly_detection) turns on Databricks data
quality monitoring (formerly anomaly detection) for the schema the
product owns: one databricks_data_quality_monitor per schema
(object_type "schema", object_id = the schema's schema_id), created after
the schema's tables so the first scan finds them, with the product's
other tables there in excluded_table_full_names.
contract.dq.monitoring.enabled: false opts an expose out. No new schema
field and no new CLI flag.

The monitor learns each table's bounds and takes no threshold (provider
1.140.0: excluded_table_full_names is its only setting), so the SLOs pick
the tables; the apply notice says so, and fluid validate warns about a
dq.monitoring.window other than one day.

A monitor turned on outside forge-cli is adopted, not created over (a
second create is 400 RESOURCE_ALREADY_EXISTS). Its import id is
"schema,<schema_id>", a UUID Databricks assigns, so ImportBlock gains
id_from: the engine completes the id from state after the schema's own
import and skips it while the schema is not there.

Removing a monitor deletes its hidden scan job and what it learned, never
table data: it joins RUN_DEFINITION_RESOURCE_TYPES (not gated, printed).

Proven live on Databricks Free Edition 2026-11-12 (scratch catalog
fluid_iactest_dqm, dropped after): apply +14, an empty second plan, a
table_results row (Training) for the watched table and none for the
excluded one, Catalog Explorer's Quality indicator, adoption of a
REST-made monitor, and an ungated opt-out.

Borrowed: the provider's databricks_data_quality_monitor resource (no
REST code); learned-vs-stated thresholds as complements follows
Elementary's freshness_anomalies beside data_freshness_sla.
…ion leaving converges

Review round 1 of the data quality monitoring change.

- When Databricks refuses the monitor (no serverless compute, no MANAGE
  on the schema), tofu apply fails after the schema's tables exist. The
  engine's _apply_failure now asks the plugin for hints on the failed
  resource (optional apply_failure_hints(address, detail), documented in
  iac/base.py); the Databricks plugin answers for a
  databricks_data_quality_monitor with what it needs and the opt-out,
  contract.dq.monitoring.enabled: false. The event stays
  opentofu_apply_failed.
- The last excluded table starting to ask shrinks the exclusion list to
  nothing. Provider 1.140.0 PATCHes update_mask=anomaly_detection_config,
  data_profiling_config with an empty config; measured live on Free
  Edition 2026-11-12, the list is gone and the next plan is empty. The
  stand-in now honours update_mask, and a test pins the request shape.
- The apply notice says Quality indicator, Catalog Explorer's own label,
  and names the opt-out; the window warning says what to do.
- The destroy-order test retries one tofu destroy on a transport error:
  the stand-in closes every connection (HTTP/1.0), and under load the
  provider can write to one it closed; state keeps the order.
@github-actions github-actions Bot added cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes needs-docs Pull request needs a linked docs update or justification labels Nov 12, 2026
@github-actions

Copy link
Copy Markdown

📄 Documentation Reminder

This PR appears to be missing a documentation reference. Our docs live in a separate repo.

Please update the PR description with one of:

  • Link a docs PR — check the "Docs PR linked" box and paste the URL
  • Mark as no docs needed — check "No docs needed" with a justification
  • Acknowledge docs TODO — check "Docs TODO" and create the docs PR before merge

See the Contributing Guide for details.

…quality-monitoring

# Conflicts:
#	HONESTLY_TESTED.md
#	tests/iac/_fake_databricks.py
@fas89
fas89 enabled auto-merge (squash) November 12, 2026 22:19
@github-actions github-actions Bot removed the needs-docs Pull request needs a linked docs update or justification label Nov 12, 2026
@fas89
fas89 merged commit 215b51d into main Nov 12, 2026
50 checks passed
fas89 added a commit that referenced this pull request Nov 12, 2026
…866)

Found while documenting #864 for forge_docs.

- Alerts: provider 1.140.0 does have notification_settings, but only
  under data_profiling_config (on_failure); anomaly_detection_config has
  only excluded_table_full_names. HONESTLY_TESTED said the provider had
  none.
- A refused monitor's hints do not come first: CLIError's three generic
  suggestions come before them, and they go ahead of the engine's own
  tofu state list hint. CHANGELOG, iac/base.py and the _apply_failure
  docstring now say that.
- The Free Edition quota covering the scans was assumed, not measured:
  the CHANGELOG now says to expect them to count against it.
fas89 added a commit that referenced this pull request Nov 12, 2026
…onitoring

#864 turns Databricks data quality monitoring on for an expose with an
anomaly_detection rule, and the monitor learns the rule's bounds, which
a dbt test cannot. The job's tests therefore leave such a rule to it
(databricks_quality.monitoring_request), as agreed with that change.
An expose that turns monitoring off (contract.dq.monitoring.enabled:
false) still has its anomaly rule refused, and the fix names the
opt-out. drift_detection stays refused.
fas89 added a commit that referenced this pull request Nov 13, 2026
…#871)

* feat(dq): read a contract's DQ rules once, as checks or refusals

fluid_build/util/dq_checks.py turns exposes[].contract.dq.rules into
engine-neutral checks (not_null, unique, accepted_values, regex, bounds,
reference, freshness, schema) and a refusal, with its fix, for each rule
no check can run as written. The allowed values of a valid_values rule
come from the selected column: its validationRules (enum 'A | B' or
'A,B', regex, range) or labels.references <product>.<column> through
consumes[]. Thresholds below 1.0 are shares computed as fluid test
computes them; critical/error map to error, warn/info to warn.

Shared with the Databricks DQ sessions (monitoring, results table,
Lakeflow expectations) as the one reading of the rules.

* feat(databricks): a dbt job runs its contract's DQ rules as dbt tests

Each check of the rules on the exposes a Databricks dbt build writes is a
singular dbt test, <test path>/fluid_dq/<model>/<check>.sql, uploaded
beside the project; the user's files upload byte for byte (dbt describes
a model in one properties entry only). dbt build runs each test after
its model, so the run page lists every rule by id and an error-severity
failure fails the run.

not_null, unique, accepted_values and relationships run dbt's built-in
test SQL through adapter.dispatch; regex, bounds, freshness, schema and
shares are forge-cli's own SQL after dbt_utils (recency,
not_null_proportion) and dbt_expectations (match_regex,
table_columns_to_match_set), both Apache-2.0, with no package to install
on serverless. Every value reaches the file as an escaped Jinja string
and every SQL literal through databricks_access.string_literal.

A rule no test can run, a Git-sourced build with rules, a model or a
referenced source the project lacks are refused at validate and plan.
The plan lists each job's tests (dq_tests); report() reads dbt's result
lines from a run's output.

* feat(dq): a reference to a product not consumed keeps no expose

A source-aligned product consumes nothing, so a labels.references to
another product's column no longer refuses in the shared reader:
Reference.expose_id is None and the engine decides whether it can find
that table (the Databricks dbt job cannot, and refuses). Two exposes of
one consumed product stay refused as ambiguous. Each refusal carries
the rule's type, so an engine that runs a type another way (Databricks
data quality monitoring for anomaly_detection) can tell its own.

* feat(databricks): the dbt job refuses an unconsumed reference; a run's report covers rules dbt stopped before

The dbt test of a reference reads the other product from the dbt source
the project declares for the expose it consumes, so a reference to a
product consumes[] does not list is refused there. report() says once
when the task's output holds no result of any rule's test (dbt stopped
before them). The job's dbt task key is a constant, DBT_TASK_KEY, which
a run's report finds the dbt output under.

* fix(databricks): the schema test refs its model while dbt parses

dbt records a test's parents while it parses, with execute false, and
dbt build --select +model+ selects tests by them. The schema test called
ref() only inside its execute guard, so it had no parent and was never
selected: live on Databricks Free Edition dbt found 11 tests and ran 10.
The relation is now set before the guard. Every kind is pinned to ref
its model at parse time.

* docs(databricks): changelog entry and the live row for DQ rules as dbt tests

The Unreleased entry says what runs for each rule type, what is refused,
and what fluid apply prints. HONESTLY_TESTED records the unit tiers and
the manual live run on Databricks Free Edition (2026-11-12): a seeded
bad row failed the run on its two error rules and warned on two, a clean
run passed all 11 tests, the next plan was empty, and everything was
swept; it also lists what was not run live.

* refactor(dq): smaller units in the rule reader and the test compiler; two hardening fixes

From the branch's security review and ripwire --quality-delta:

- The schema test escapes the expected type as it escapes the type the
  warehouse reports. It is unreachable today (every Databricks column
  type passes _column_type first), but no longer depends on it (LOW).
- A reference refusal quotes the labels.references parts it names (LOW).
- compile_tests runs in stages (a _Project with its paths and lazily
  read sources, _test_folder, _add_test); _declared_sources reads
  through _properties_docs and _source_tables; _Rule._allowed reads each
  validationRule kind in its own method; read_rules and has_rules share
  _parts; report's wording is _outcome.
- The job's tests ride on _Source, so _emit_job keeps its signature and
  its task dict stays one line.

* feat(dq): each check keeps its rule's severity, threshold and operator as written

DqCheck.rule_severity, rule_threshold and rule_operator carry the rule
as the contract wrote it, beside the two-level severity a dbt test runs
at and the share a lower threshold becomes: Lakeflow expectations fail
an update on critical and drop the row on error, and a results table
reports one row per rule.

* fix(databricks): refuse a test name the project uses and a selection that skips the model

From the diff's delivery review:

- A rule's test is named after its id, and dbt names a singular test once
  per project: a project test of the same name made the whole job fail to
  parse ("dbt found two data_tests with the name"). It is refused at plan.
- A build whose dbt selection provably leaves the model out would never
  run its tests: refused, with the model's fqn and path evaluated by
  databricks_retention.selects_model. A tag, config or state selection
  cannot be told here; the run's report names any test that did not run.
- A real dbt (dbt-core with dbt-duckdb, as CI installs) parses the upload
  and dbt ls --select +orders+ lists every rule's test; with the schema
  test's ref put back behind its execute guard, it fails.
- Wording: a share is fluid test's own only for completeness and
  uniqueness; the anomaly and drift refusal says what fluid test does
  with them; dq_checks is the reading for the engines that run the rules,
  this job its first.

* feat(databricks): fluid apply reports what dbt found for each contract rule after a run

Under --mode amend-and-build, once the job's run ends (success or
failure), the run report reads the dbt task's output and prints one line
per contract rule's test: pass, warn or fail with its failing-row count,
error, skip, or not in the output (with the note when Databricks kept only
its end), then the summary. The rules come from the contract alone
(databricks_dbt_tests.expected_tests); the task is found by DBT_TASK_KEY,
so the fluid_keys task beside it is never read as dbt's.

runs/get-output is read once per task and feeds the dbt summary, the
failed task's tail and the rules alike (_task_output). A rule report is
printed for the dbt task when a later task failed too: its tests ran.
Never fails a build: an unreadable output or contract only drops the
report.

* fix(dq): refuse a threshold or bound no float holds, and a range no value meets

From the diff's defect scan: an integer threshold of hundreds of digits
raised OverflowError out of fluid validate and plan, and so did a window
too long to count; an infinite or NaN threshold, or a range bound past a
float's range, reached the SQL as a bare 'inf'. Each is now a refusal
(_finite, and parse_range drops a non-finite bound). A range no value
can meet (between 100 and 0, > 5 and < 1, > 3 and <= 3) is refused
instead of failing every row of its test.

* fix(databricks): defect-scan fixes to the DQ tests and the keys task

- Only the exposes with rules are tested, so only their models are looked
  for: a sibling output with no rules (no model of its name, or left out
  of the selection) was refused.
- properties.model names the model of the one table a build writes; a
  build that writes several (no outputs writes every expose) tests each
  through the model named after its table.
- fluid validate, which reads the contract before {{ env.* }} is
  substituted, no longer refuses a model named by a placeholder; the
  apply writes those tests.
- The schema test spells a decimal as the table emitter does
  (_canonical_type): decimal(12) is decimal(12,0), as Databricks reports.
- _jinja_str keeps characters outside the BMP whole: ensure_ascii wrote
  a surrogate pair, which dbt cannot encode (one emoji in a rule id
  failed the whole project's parse).
- The test-name clash check also covers the project's own files under
  fluid_dq/, and a project YAML nested past the recursion limit is passed
  over like any file that does not parse.
- The fluid_keys task (#860) runs with run_if ALL_DONE: a failing rule
  fails dbt after its model replaced the table, which dropped its keys.

* docs(databricks): AGENTS.md and the live row for the DQ tests and their report

AGENTS.md says the job runs the contract's DQ rules as dbt tests and
where the reader and the renderer live; the changelog says the keys task
now runs whatever dbt's outcome; HONESTLY_TESTED counts the tests again
(48 + 54 + 4) and lists what the review waves added.

* feat(databricks): an anomaly_detection rule is left to data quality monitoring

#864 turns Databricks data quality monitoring on for an expose with an
anomaly_detection rule, and the monitor learns the rule's bounds, which
a dbt test cannot. The job's tests therefore leave such a rule to it
(databricks_quality.monitoring_request), as agreed with that change.
An expose that turns monitoring off (contract.dq.monitoring.enabled:
false) still has its anomaly rule refused, and the fix names the
opt-out. drift_detection stays refused.

* fix(databricks): refuse an unpaired surrogate; a placeholder selection waits for the apply

From the fix wave's re-check:

- Since the Jinja strings keep raw Unicode, an unpaired surrogate (YAML's
  "\ud83d" escape loads one) in a rule id, an enum value or a regex
  raised UnicodeEncodeError out of fluid plan. The reader refuses one with
  the control characters, in ids too, and a value from the project's own
  YAML (a source name) that UTF-8 cannot hold is a refusal of its own.
- fluid validate, before {{ env.* }} is substituted, refused a build whose
  properties.select still held a placeholder as leaving the model out; the
  selection is judged only once it can be read, at the apply.

* docs(databricks): the anomaly hand-off to monitoring, and the counts, in the changelog and the live row

* fix(databricks): refuse a test path no file can hold; monitoring takes over only the history refusal

From the round-2 re-check:

- An unpaired surrogate in dbt_project.yml's first test path (YAML's
  "\ud83d" escape loads one) reached the generated tests' paths and raised
  UnicodeEncodeError out of fluid plan. It is a test-path refusal now, and
  the message names the path escaped.
- On an expose Databricks data quality monitoring watches, every refusal of
  an anomaly_detection rule was dropped, so a bad severity or an id holding
  a control character passed silently. DqRefusal.needs_history marks the
  one refusal monitoring takes over (the rule needs the table's history);
  the others are refused as before.

* fix(databricks): a DQ refusal names a lone surrogate from the project escaped

The round-3 re-check found model-paths reaching the model refusal's text
raw, and a lone surrogate there (a YAML escape loads one) made fluid plan
fail on printing the refusal. Every DQ refusal is built in _refusal, which
now spells such a character as its backslash escape; a source or table
name from the project's YAML in a reference refusal is covered the same way.

* docs(databricks): the live row counts the round-3 tests and names the escaped refusal text

* fix(dq): a valid_values rule allows what fluid test allows

#867 landed fluid test's reading of a valid_values rule,
providers/quality_engine.py::value_domain: the values the rule itself
lists, else every declaration its column makes (the first enum, regex and
range validationRule, and labels.references), all of which must hold.
dq_checks had its own, which let the column's declarations win over the
rule's list, split an enum on | only when it held one, and read every
validationRule of a kind; the dbt tests could then check other values than
fluid test does.

dq_checks now takes the values, pattern and bounds from value_domain and
keeps its refusals on top: a range fluid test cannot read (it passes one
over) or no value can meet, a value or pattern holding a control
character, and a labels.references that consumes[] cannot resolve. Its own
parse_enum and parse_range are gone; the range grammar is quality_engine's.
The Tower Management pack still reads as 330 checks with none refused,
check for check as before, and the demo's dbt products as 230 tests.

* docs(dq): valid_values reads as fluid test's value_domain, in AGENTS.md, the changelog and the live row

* chore(secrets): baseline line numbers after the rebase on #865 and #869

* test(databricks): the schema test's expected rows join their parts with an explicit +
@fas89 fas89 mentioned this pull request Nov 13, 2026
1 task done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Changes to the CLI surface or implementation tests Test coverage or test infrastructure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant