Repository navigation
Description
Is there an existing issue for this?
- I have searched the existing issues
Problem statement
Found while building a regulatory data-quality demo with DQX Studio (studio-v0.1.0-rc.4; the code below is unchanged on main as of b645540).
Studio's duplicate detection ignores the filter of a check. Two rules that run the same check on the same column but on different row subsets are treated as the same rule. Example:
- criticality: error
check:
function: is_in_list
arguments: {column: status, allowed: [OK, PENDING]}
filter: error_code = 100
- criticality: error
check:
function: is_in_list
arguments: {column: status, allowed: [OK, PENDING]}
filter: error_code = 200These are two different rules, and DQX core and the Studio Registry both treat them that way. Two Studio code paths do not:
1. Submit/publish guard. _check_no_duplicate_pending_or_approved compares only the inner check block (function + arguments), so the top-level filter is not part of the comparison:
dqx/app/src/databricks_labs_dqx_app/backend/services/rules_catalog_service.py
Lines 570 to 584 in 0699653
| def _check_no_duplicate_pending_or_approved(self, entry: RuleCatalogEntry) -> None: | |
| """Raise ValueError if another pending/approved rule with the same check exists.""" | |
| check_sig = json.dumps(entry.checks[0].get("check", {}) if entry.checks else {}, sort_keys=True) | |
| siblings = self.list_rules_for_table(entry.table_fqn) | |
| for sibling in siblings: | |
| if sibling.rule_id == entry.rule_id: | |
| continue | |
| if sibling.status not in ("pending_approval", "approved"): | |
| continue | |
| sib_sig = json.dumps(sibling.checks[0].get("check", {}) if sibling.checks else {}, sort_keys=True) | |
| if sib_sig == check_sig: | |
| raise ValueError( | |
| f"A duplicate rule with the same check already exists " | |
| f"(rule_id={sibling.rule_id}, status={sibling.status}). " | |
| f"Cannot submit a duplicate for approval." |
When a monitored table is published, its checks are moved draft → pending_approval one at a time. The second check fails with ValueError, which is only logged and skipped. The check silently stays in draft, and the UI only receives a count of submitted checks:
dqx/app/src/databricks_labs_dqx_app/backend/routes/v1/monitored_tables.py
Lines 1247 to 1281 in 0699653
| def _transition_binding_checks( | |
| monitored_tables_svc: MonitoredTableService, | |
| rules_catalog: RulesCatalogService, | |
| binding_id: str, | |
| *, | |
| from_status: str, | |
| to_status: str, | |
| user_email: str, | |
| ) -> int: | |
| """Move every materialized check of *binding_id* from *from_status* to *to_status*. | |
| Reuses ``RulesCatalogService.set_status`` (the per-rule transition path) | |
| so nothing about a check's audit trail differs from a hand-submitted one. | |
| Per-row failures (e.g. a duplicate-pending guard) are logged and skipped | |
| rather than aborting the whole binding — mirroring | |
| ``RulesCatalogService.set_status_by_table``'s resilience. Returns the | |
| count of checks actually transitioned. | |
| """ | |
| count = 0 | |
| for rule_id, status in monitored_tables_svc.list_materialized_rule_statuses(binding_id): | |
| if status != from_status: | |
| continue | |
| try: | |
| rules_catalog.set_status(rule_id, to_status, user_email) | |
| count += 1 | |
| except Exception: # one bad row must not abort the whole binding | |
| logger.warning( | |
| "Failed to transition materialized check %s (%s -> %s) for binding %s", | |
| rule_id, | |
| from_status, | |
| to_status, | |
| binding_id, | |
| exc_info=True, | |
| ) | |
| return count |
2. Batch import with skip_duplicates. Reuse is keyed on shape_key, which hashes mode, polarity, body, slots and parameters, but not definition.filter:
dqx/app/src/databricks_labs_dqx_app/backend/services/generic_rule_shape.py
Lines 49 to 68 in 0699653
| def shape_key(mode: str, definition: RuleDefinition, polarity: str | None) -> str: | |
| """Hash of the rule's structure with slots renamed to their position.""" | |
| ordered = _ordered_slots(definition.slots) | |
| positional = {s.name: f"__slot_{i}" for i, s in enumerate(ordered)} | |
| body = _rename_placeholders(definition.body, positional) if mode in _RENAMEABLE_MODES else definition.body | |
| payload = { | |
| "mode": mode, | |
| "polarity": polarity, | |
| "body": body, | |
| "slots": ( | |
| [positional[s.name] + ":" + s.cardinality for s in ordered] | |
| if mode in _RENAMEABLE_MODES | |
| else [s.name + ":" + s.cardinality for s in ordered] | |
| ), | |
| "parameters": sorted( | |
| ({"name": p.name, "type": p.type, "value": p.value} for p in definition.parameters), | |
| key=lambda p: p["name"], | |
| ), | |
| } | |
| return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest() |
dqx/app/src/databricks_labs_dqx_app/backend/routes/v1/registry_rules.py
Lines 327 to 350 in 0699653
| try: | |
| key: str | None = None | |
| if body.skip_duplicates: | |
| key = shape_key(rule_in.mode, rule_in.definition, rule_in.polarity) | |
| match = seen_in_batch.get(key) or existing_by_shape.get(key) | |
| if match is None: | |
| exact = svc.get_active_rule_by_fingerprint( | |
| svc.compute_definition_fingerprint(rule_in.mode, rule_in.definition, rule_in.polarity) | |
| ) | |
| if exact is not None and ( | |
| not body.generalize_slots or exact.source != "import" or _has_neutral_slots(exact) | |
| ): | |
| match = RegistryRuleOut.from_domain(exact) | |
| if match is not None: | |
| renames = slot_renames_between(rule_in.definition, match.definition) | |
| reused.append( | |
| CreateRegistryRuleOut( | |
| rule=match, | |
| input_index=index, | |
| slot_renames={k: v for k, v in renames.items() if k != v}, | |
| ) | |
| ) | |
| seen_in_batch[key] = match | |
| continue |
The second rule is reported as "reused" and replaced by the first, so it ends up with the first rule's filter (error_code = 100 instead of error_code = 200).
In contrast, the other identity functions include the filter:
- DQX core
compute_rule_fingerprint:dqx/src/databricks/labs/dqx/rule.py
Lines 672 to 679 in 0699653
fingerprint_data = { "name": check_dict.get("name"), "criticality": check_dict.get("criticality", "error"), "function": check_inner.get("function"), "arguments": check_inner.get("arguments"), "filter": check_dict.get("filter"), "for_each_column": sorted(for_each_column) if for_each_column else None, } - Studio Registry fingerprint:
"filter": rule.definition.filter,
Ignoring the rule name in the duplicate check is intentional and fine. As the _check_signature docstring explains, profiler-generated and manually created rules with the same check should match:
dqx/app/src/databricks_labs_dqx_app/backend/services/rules_catalog_service.py
Lines 345 to 357 in 0699653
| @classmethod | |
| def _check_signature(cls, check: dict[str, Any]) -> str: | |
| """Produce a normalised, case-insensitive identity for a check. | |
| Only the function name and semantically meaningful arguments are | |
| included. Options like ``trim_strings``, ``name``, ``weight``, and | |
| ``criticality`` are excluded so that profiler-generated rules and | |
| manually-created rules with the same function + column are treated as | |
| duplicates. | |
| """ | |
| inner = check.get("check", check) | |
| fn = inner.get("function", "") | |
| raw_args = inner.get("arguments", {}) |
The problem is only the filter.
Proposed Solution
- Include the normalized
filterin the duplicate comparison of_check_no_duplicate_pending_or_approved(and in_check_signature, whichfind_duplicatesuses and which also ignores it). Keep ignoringname,criticality, etc. - Include
definition.filterinshape_key, soskip_duplicatesonly collapses rules that also share the same filter. This would matchregistry_fingerprint. - When a check of a monitored table is not submitted/published (duplicate guard or any other per-row failure in
_transition_binding_checks), return the skipped checks and the reason in the response and show them in the UI, instead of only logging a warning.
Suggested tests: two checks that differ only in filter both reach pending_approval on table submit; a batch import with skip_duplicates=true of two rules that differ only in filter creates two rules; two checks that differ only in name are still flagged as duplicates.
Additional Context
filter is part of what a rule checks: it selects the rows the check runs on. A common pattern in regulatory data-quality checks is to apply the same domain check to different record types selected by a code column.
Activity
mwojtyczka commentedon Oct 15, 2026
@anasanchezss9 thank you for reporting this. Would you like to work on this?
OGordon100 commentedon Oct 19, 2026
@mwojtyczka @anasanchezss9 thanks for the issue.
Will be fixed in https://github.com/databrickslabs/dqx/tree/dqx-studio/filtered-score
fix(app): keep imported rules that differ only in filter apart