Skip to content

feat(actions): Pluggable DQ Actions & Alerting#1289

Open
mwojtyczka wants to merge 90 commits into
mainfrom
alerting
Open

feat(actions): Pluggable DQ Actions & Alerting#1289
mwojtyczka wants to merge 90 commits into
mainfrom
alerting

Conversation

@mwojtyczka

@mwojtyczka mwojtyczka commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an extensible actions subsystem to DQX. An action runs when data checked by DQX violates an (optional) condition evaluated against the summary metrics produced by DQMetricsObserver. The concrete actions are:

  • DQAlert — sends a notification to one or more destinations: Slack, Microsoft Teams, generic HTTPS webhook, Log (driver logger, no external system) or an in-process callback.
  • FailPipeline — raises PipelineFailedError to stop the current pipeline run.
  • NoOpAction — fires with no side effect (records the event only). Serves as an observe-only / dry-run mode and as a side-effect-free building block for testing action semantics.

Actions can be defined programmatically (DQX classes) or declaratively as metadata (YAML/JSON dicts), and are passed to DQEngine(ws, observer=..., actions=[...]) — they fire automatically on the save-to-table methods (batch and streaming), or explicitly via engine.evaluate_actions(...). Action definitions can be stored/loaded from UC or Lakebase tables (or local YAML/JSON files) via DQActionManager, and action events can be persisted to a UC/Lakebase events table so frequency/status-change suppression survives engine restarts. In the installed DQX Workflows and the parallel multi-run-config runner, each RunConfig can point at its own actions_location (definitions) and action_events_location (history) so actions are auto-loaded and applied per run config.

What's included

  • New package databricks.labs.dqx.actionsDQAction, Action ABC, DQAlert, FailPipeline, NoOpAction; AlertDestination hierarchy (Slack / Teams / webhook / log / callback); a safe AST condition evaluator; standard message builder; webhook delivery with retry/backoff and an SSRF guard; SecretResolver (DQSecret); ActionStateStore + UC/Lakebase event stores; ActionEvaluator orchestrator; ActionSerializer + UC/Lakebase definition storage; DQActionManager. Destinations and actions are Pydantic discriminated unions, so adding a new type is a small, isolated change (OCP).
  • Engine integrationactions= on DQEngine/DQEngineCore (accepts DQAction instances or raw metadata dicts), batch + streaming firing, evaluate_actions(...), and optional action_events_config for persistent state/history.
  • Metadata (YAML/JSON) API — actions can be authored as dicts/YAML with the same wire format as checks; DQActionManager.load_actions_from_local_file / save_actions_in_local_file round-trip them to disk.
  • Workflow & run-config integrationDQEngine.apply_checks_and_save_in_tables (and the installed quality-checker workflow) auto-load each RunConfig.actions_location and fire those actions for that run. Each run config is applied through a dedicated engine (its own actions, a fresh observer, and an optional event store), keeping the parallel runner thread-safe. action_events_location persists event history and durable alert suppression across runs.
  • ConfigDQSecret, TableActionsStorageConfig, LakebaseActionsStorageConfig, ActionEventsConfig, RunConfig.actions_location (definitions) and RunConfig.action_events_location (event history).
  • Demodemos/dqx_demo_alerting.py demonstrating alerting via the log destination (with optional Slack), wired into the e2e demo runner.

Known follow-ups (out of scope here)

  • Fully asynchronous off-thread streaming webhook delivery — not needed for practical use. Streaming action evaluation runs on Spark's StreamingQueryListener thread (not the micro-batch data path), so synchronous delivery blocks only that callback, and alerts are rate-limited (HOURLY/DAILY) and fire only on threshold crossings — the documented small webhook timeout/retry budget keeps the callback fast. Off-thread delivery would only help high-throughput streaming that fires alerts frequently against slow endpoints (where a slow callback could let Spark drop progress events). Documented; deferred unless such a use case appears.
  • FailPipeline in streaming is best-effort. It aborts immediately on the batch path, but in streaming it is raised inside the listener callback and Spark generally isolates/logs listener exceptions rather than stopping the query — so it is not guaranteed to hard-stop a streaming query. Documented; a guaranteed streaming stop (e.g. signalling query.stop() from the listener) is a possible follow-up that needs live verification.
  • Email notification.

Linked issues

Resolves #204 #610 #1214

Tests

  • added unit tests
  • added integration tests
  • added e2e tests

Documentation and Demos

  • added/updated demos
  • added/updated docs
  • added/updated agent skills

Add six new exception classes to errors.py (TerminalActionError,
PipelineFailedError, InvalidConditionError, InvalidActionError,
AlertDeliveryError, UnsafeWebhookUrlError), and new config dataclasses
DQSecret, TableActionsStorageConfig, LakebaseActionsStorageConfig,
ActionEventsConfig to config.py; add actions_location field to RunConfig.

Co-authored-by: Isaac
- LakebaseActionsStorageConfig.instance_name changed from str|None=None
  to a required str field; required fields (location, instance_name) now
  precede defaulted ones; dead-code guard removed from _split_location.
- TableActionsStorageConfig and ActionEventsConfig __post_init__ now
  validate mode is 'append' or 'overwrite', matching LakebaseActionsStorageConfig.
- All inline imports in test_action_config.py hoisted to module level to
  satisfy pylint C0415.

Co-authored-by: Isaac
Introduces databricks.labs.dqx.actions package with ConditionEvaluator
that gates DQ actions on metric expressions using a safe AST walker — no
eval/exec. Supports arithmetic, comparison, boolean, and literal nodes;
raises InvalidConditionError for any other node type or unknown metric.

Co-authored-by: Isaac
…p operator errors

- Add _validate_tree() that uses ast.walk to visit every AST node and
  reject disallowed types before any evaluation begins; called
  unconditionally at the top of both validate() and evaluate() so
  short-circuit evaluation cannot bypass the allowlist.
- Add _ALLOWED_NODE_TYPES frozenset (single definition, reused by
  the pre-pass); includes ast.Load and abstract base types produced
  by ast.walk on valid conditions.
- Wrap operator application in _eval_binop, _eval_compare, and
  _eval_unaryop with try/except (ZeroDivisionError, TypeError,
  OverflowError) and re-raise as InvalidConditionError.
- Remove redundant type(node.op) not in _BOOL_OPS guard in
  _eval_boolop (unreachable — only And/Or are BoolOp ops).
- Add 6 new tests: full-tree pre-pass coverage and operator-error
  wrapping; total 58 tests, all passing.

Co-authored-by: Isaac
Add AlertMessage frozen dataclass and StandardMessageBuilder to
actions/message.py; builder takes primitives to avoid a circular import
with ActionContext (Task 5). 26 unit tests cover the TDD RED/GREEN cycle.

Co-authored-by: Isaac
…ey collision

- Change test_is_frozen to assert dataclasses.FrozenInstanceError specifically
  instead of the overly broad Exception.
- Prefix per-metric entries in the fields dict with "metric." (e.g.
  "metric.error_row_count") so a metric named after a reserved key
  (condition, run_id, run_time, table) cannot silently overwrite or be
  overwritten by the metadata entries. observed_metrics remains un-prefixed.
- Update existing field-assertion tests to use the new "metric.<name>" keys.
- Add TestStandardMessageBuilderReservedKeyCollision test that verifies both
  fields["metric.condition"] and fields["condition"] coexist correctly.

Co-authored-by: Isaac
Adds SecretResolver to the actions package, resolving plain strings
as-is and DQSecret references via ws.dbutils.secrets.get at delivery
time. API failures are wrapped in InvalidParameterError without leaking
the resolved secret value.

Co-authored-by: Isaac
Introduces the foundational building blocks for the DQX actions &
alerting subsystem: ActionStatus enum, ActionContext / ActionResult /
ActionServices frozen dataclasses, the Action abstract base class, and
DQAction (condition + action binding with eager validation).
WebhookClient and SparkSession are guarded behind TYPE_CHECKING to keep
the module importable without delivery.py or PySpark present.

Co-authored-by: Isaac
Move sys import to top-level, replace abstract-instantiation test with
inspect.isabstract check, remove unused error re-exports from base.py,
and delete the test_action_base.py per-file pylint override block.

Co-authored-by: Isaac
Implements WebhookAuth, validate_webhook_url (SSRF guard), and WebhookClient
(urllib-only, no-redirect opener, exponential-backoff retry, no secrets in errors).

Co-authored-by: Isaac
…able 4xx

Match stdlib redirect_request signature and type the opener param so mypy
needs no overrides; catch OSError once (HTTPError subclass) with a single
last_exc assignment to satisfy pylint and remove duplicated backoff; fail
fast on non-retryable 4xx (not 429); avoid type-ignores in tests.

Co-authored-by: Isaac
Adds CallbackDQAlertDestination, an in-process destination that invokes
a user-supplied Python callable on delivery.  Not serializable (Task 11
skips it); validate() enforces non-empty name and a callable callback.

Co-authored-by: Isaac
Implements DQAlert (with DQAlertFrequency/NotifyOn enums) for concurrent
multi-destination alerting with per-destination error isolation, and
FailPipeline which raises PipelineFailedError to terminate the DQX run.

Co-authored-by: Isaac
… type-ignores

- DQAlert.validate() now rejects duplicate destination names (they would
  silently clobber entries in ActionResult.destination_errors).
- Drop spurious '# type: ignore[import-untyped]' on the typed WebhookClient
  import and replace a None-defaulted list field with field(default_factory).
- Broaden the CWE-117 sanitization test to cover tab/ANSI/null control chars.

Co-authored-by: Isaac
…ept in event-store test

Add explicit Callable return type (AGENTS all-annotations rule), remove the
redundant try/except around DROP TABLE IF EXISTS in the integration cleanup
fixture, and add an exact-1h HOURLY boundary test.

Co-authored-by: Isaac
Implements ActionSerializer (with registry-based OCP design for actions/destinations,
DQSecret tagged-dict round-trip, CallbackDQAlertDestination skip with warning),
TableActionsStorageHandler and LakebaseActionsStorageHandler, ActionsStorageHandlerFactory,
and DQActionManager for save/load of DQAction definitions to UC Delta or Lakebase.
Adds 23 unit tests (all passing) and integration tests for the UC Delta path.

Co-authored-by: Isaac
… fully OCP

Validate run_config_name via re.fullmatch before Delta replaceWhere interpolation
(mirrors checks_storage.py guard; raises UnsafeSqlQueryError for unsafe chars).
Extracted into pure helper build_replace_where_predicate for unit-testability.

Added _ACTION_SERIALIZERS and _DESTINATION_SERIALIZERS registries to serializer.py
so both serialize and deserialize sides are registry-driven (no isinstance chains).
Adding a new action/destination type now requires only one registry entry per side.

Co-authored-by: Isaac
delivery.py exists and is fully typed since Task 6, so the TYPE_CHECKING
import no longer needs the suppression.

Co-authored-by: Isaac
Replace bare operator.* references (typed as object) in _UNARY_OPS,
_BIN_OPS, _CMP_OPS, and _EVALUATORS with thin typed wrapper functions
(lhs/rhs/val params, cast to float for numeric ops) and precise
Callable/dict value types. Node-evaluator functions now share a uniform
(ast.AST, metrics) -> object signature via internal cast. Zero inline
type: ignore remain; pylint 10/10, mypy and ruff clean, 58 tests pass.

Co-authored-by: Isaac
…re-abort

Remove dead _healthy_result helper, update module docstring, and add two
new tests: one proving deferred[0] (first terminal error) is raised rather
than the last, and one proving alert actions execute and record before a
subsequent terminal action aborts the pipeline.

Co-authored-by: Isaac
… UTC handling

- event_storage: skip rows with a NULL run_time on read (both Delta and Lakebase
  stores, load_latest and load_last_fired) so a corrupt or externally-inserted
  timestamp can no longer crash seed() via to_utc(None)
- conditions: InvalidConditionError now references the user's original condition
  string rather than the operator-normalized form; _normalize_operators docstring
  uses italics instead of backticks per the docstring convention
- tests: cover all to_utc branches (naive->UTC, UTC-aware unchanged, non-UTC-aware
  converted via astimezone)

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Add error_if & warn_if settings [FEATURE]: Conditionally failing checks [FEATURE]: Add data quality alerts

1 participant