Skip to content

Add DLQ rule action to Schema Registry serdes#2310

Open
Justin Wang (Claimundefine) wants to merge 7 commits into
masterfrom
dlq-action
Open

Add DLQ rule action to Schema Registry serdes#2310
Justin Wang (Claimundefine) wants to merge 7 commits into
masterfrom
dlq-action

Conversation

@Claimundefine

@Claimundefine Justin Wang (Claimundefine) commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Port the Java client's DlqAction to confluent-kafka-python. When a data-contract rule (CSFLE encryption, CEL condition, etc.) fails during serialize/deserialize and the rule's onFailure is set to "DLQ", the record is teed to a dead-letter-queue topic and a SerializationError is still raised (tee, not swallow).

  • DlqAction + FieldRedactionExecutor under rules/dlq/
  • Capture original key/value for the DLQ record via a contextvar (thread- and task-safe), mirroring the Java client's ThreadLocal
  • Redact fields tagged by encryption rules before JSON-encoding so plaintext does not leak to the DLQ
  • Skip a rule on replay when the record carries the matching __rule.name header
  • Configure registered rule actions in each serde; close owns non-global registries

What

Checklist

  • Contains customer facing changes? Including API/behavior changes
  • Did you add sufficient unit test and/or integration test coverage for this PR?
    • If not, please explain why it is not required

References

JIRA:

DGS-14069

Test & Review

Open questions / Follow-ups

Copilot AI review requested due to automatic review settings July 20, 2026 05:07
@confluent-cla-assistant

Copy link
Copy Markdown

🎉 All Contributor License Agreements have been signed. Ready to merge.
Please push an empty commit if you would like to re-run the checks to verify CLA status for all contributors.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a Schema Registry “DLQ” rule action to the Python serdes, mirroring the Java client’s behavior: when a rule fails with onFailure=DLQ, the original record is teed to a configured dead-letter topic while still raising SerializationError. It also adds field redaction for tagged fields (to avoid plaintext leakage) and replay-skipping via the __rule.name header.

Changes:

  • Introduces DlqAction + FieldRedactionExecutor, including byte conversion, header population, redaction, and producer lifecycle behavior.
  • Captures original key/value via a contextvars.ContextVar and threads that through rule execution so DLQ sends can use the “entry” payload/wire bytes.
  • Adds comprehensive unit + integration tests for sync/async Avro/JSON/Protobuf paths, replay behavior, redaction, and close/ownership semantics.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/schema_registry/test_dlq_action.py Unit tests for DlqAction conversion, topic resolution, headers, redaction, replay detection
tests/schema_registry/_sync/test_dlq_serdes.py Sync serde tests covering DLQ behavior across Avro/JSON/Protobuf, redaction, replay skip, close semantics
tests/schema_registry/_async/test_dlq_serdes.py Async serde tests mirroring sync coverage for DLQ behavior, redaction, replay skip, close semantics
tests/integration/schema_registry/_sync/test_dlq.py Sync integration test validating DLQ tee + replay-skip with a real Kafka cluster
tests/integration/schema_registry/_async/test_dlq.py Async integration test validating DLQ tee + replay-skip with a real Kafka cluster
src/confluent_kafka/schema_registry/rules/dlq/dlq_action.py Implements DlqAction + FieldRedactionExecutor (produce-to-DLQ, redaction, headering, config)
src/confluent_kafka/schema_registry/rules/dlq/init.py Adds dlq rules package marker
src/confluent_kafka/schema_registry/common/serde.py Adds DLQ header constants + contextvar-based original key tracking; extends RuleContext with original key/value
src/confluent_kafka/schema_registry/_sync/serde.py Threads original-message support through rule execution; adds DLQ replay-skip; adds close() ownership semantics for registries
src/confluent_kafka/schema_registry/_sync/protobuf.py Configures actions; captures/clears original key; passes wire bytes into READ encoding-phase rule execution
src/confluent_kafka/schema_registry/_sync/json_schema.py Configures actions; captures/clears original key; passes wire bytes into READ encoding-phase rule execution
src/confluent_kafka/schema_registry/_sync/avro.py Configures actions; captures/clears original key; passes wire bytes into READ encoding-phase rule execution
src/confluent_kafka/schema_registry/_async/serde.py Async analogue of sync serde updates: original-message threading, DLQ replay-skip, aclose() ownership semantics
src/confluent_kafka/schema_registry/_async/protobuf.py Async analogue: action configure, original key capture/clear, pass wire bytes for READ encoding-phase
src/confluent_kafka/schema_registry/_async/json_schema.py Async analogue: action configure, original key capture/clear, pass wire bytes for READ encoding-phase
src/confluent_kafka/schema_registry/_async/avro.py Async analogue: action configure, original key capture/clear, pass wire bytes for READ encoding-phase

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Port the Java client's DlqAction to confluent-kafka-python. When a data-contract
rule (CSFLE encryption, CEL condition, etc.) fails during serialize/deserialize
and the rule's onFailure is set to "DLQ", the record is teed to a dead-letter-queue
topic and a SerializationError is still raised (tee, not swallow).

- DlqAction + FieldRedactionExecutor under rules/dlq/
- Capture original key/value for the DLQ record via a contextvar (thread- and
  task-safe), mirroring the Java client's ThreadLocal
- Redact fields tagged by encryption rules before JSON-encoding so plaintext does
  not leak to the DLQ
- Skip a rule on replay when the record carries the matching __rule.name header
- Configure registered rule actions in each serde; close owns non-global registries

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single DlqAction is shared across every serde bound to the same RuleRegistry,
so each serde constructor may call configure() concurrently. configure() mutated
the shared _conf dict without holding the lock that _get_producer() uses while
iterating it, so a serde constructed concurrently with the first DLQ send could
trigger "RuntimeError: dictionary changed size during iteration" or build the
producer from a half-written config.

- configure() now mutates _conf and derives fields under self._lock; _parse_conf()
  stays lock-free (called with the lock held, or from __init__ before sharing) to
  avoid re-entrant deadlock.
- close() detaches the producer under the lock, then flushes outside it (flush can
  block indefinitely with message.timeout.ms=0 and must not stall other callers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Schema Registry DLQ rule action captures the value's original key via
a contextvar that the key serde stashes for the value serde to read. This
assumes the key is processed before the value, which holds on produce
(SerializingProducer) and in the Java client, but DeserializingConsumer and
DeserializingShareConsumer deserialized the value first. As a result, a
READ-rule failure teed a DLQ record with the *previous* message's key (or
None for the first message) instead of the current one.

Deserialize the key before the value in both consumers so the value serde
sees the current message's key. This aligns the consume path with the
produce path and the Java client. As a side effect, when both key and value
fail to deserialize, the key error is now surfaced instead of the value
error.

Also extract the classic consumer's deserialization into a _deserialize()
method (mirroring DeserializingShareConsumer) so it can be unit-tested
without a broker, and correct the DLQ docstrings that claimed the value
serde clears the stashed key "on entry" (it clears on exit, after use).

- Reorder DeserializingConsumer / DeserializingShareConsumer to key-first
- Add tests/test_DeserializingConsumer.py; update share-consumer tests that
  pinned the old value-first order
- Update dlq_action / common serde docs and CHANGELOG

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up review changes to the Schema Registry DLQ action:

- Add a durability contract to the DlqAction docstring: with the default
  (global) RuleRegistry the DLQ is best-effort (records teed just before
  process exit may be dropped). For durability, set dlq.auto.flush=true or
  give the serde its own RuleRegistry and call close()/aclose() on shutdown.
- Reword DLQ comments and docstrings to describe the Python implementation
  directly instead of referencing the Java client, across dlq_action.py,
  schema_registry/_async|_sync/serde.py, common/serde.py,
  deserializing_consumer.py, and the DLQ tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes to the DLQ rule action's shutdown and redaction behavior, all
exercised by the DLQ_DESIGN "dedicated registry" durable-shutdown path:

1. FieldEncryptionExecutor.close() referenced a non-existent self.client and
   raised AttributeError. On the recommended shutdown path (a dedicated
   registry holding a FieldEncryptionExecutor + DlqAction), the serde's close
   loop hit this first, so the DlqAction never flushed -- buffered DLQ records
   were lost and the KMS client leaked. Delegate to the wrapped
   EncryptionExecutor, which owns the client.

2. BaseSerde.close()/aclose() closed every executor then every action with no
   error isolation, so one failing close skipped the rest -- including the
   DlqAction flush. Flush actions first and wrap each close in try/except, so a
   buggy executor can't cost buffered DLQ records.

3. Redaction mutated the caller's message in place, so a WRITE failure (e.g. a
   transient KMS/DEK outage) silently replaced the app's live PII fields with
   "<REDACTED>"; retrying the same object would then serialize masked data.
   Redact a deep copy instead. The DLQ record bytes are identical, so this is a
   local-only divergence from Java with no cross-client effect.

Also document the ENCRYPT_PAYLOAD-on-WRITE plaintext exposure prominently (a
payload-level rule has no field tags to redact, so a WRITE failure tees the full
plaintext record; matches Java, so behavior is unchanged).

Adds regression tests: FieldEncryptionExecutor.close() does not raise; the
real-executor durable-shutdown path flushes the DLQ; a failing executor is
isolated and the DLQ still flushes; and redaction leaves the caller's object
intact. (_sync regenerated from _async via unasync.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The synthetic redact rule built in _redact_fields is labelled with
DlqAction.TYPE ("DLQ"), matching the Java client. This is harmless: the
rule drives FieldRedactionExecutor.transform() directly (never looked up
by type), and in the single-rule context are_transforms_with_same_tag()
is unreachable, so rule.type is never read. Add a comment so the value
is not mistaken for a bug and stays in sync with Java for any future
type-based dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Condense the over-verbose DLQ docstrings and inline comments to fit the
surrounding code (e.g. DlqAction's ~110-line class docstring down to ~20,
multi-line inline comments to one or two lines each), with no behavior
change. Also move the DLQ note in the changelog from Fixes to Enhancements
(it is a new feature) and slim the key-before-value Fixes entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqube-confluent

Copy link
Copy Markdown

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.

2 participants