Recover from Kafka outages and duplicate delivery#

Goal. Work through a bounded, reproducible example and inspect the evidence before connecting an external service.

Prerequisites. Base FraudTwin install. Optional extras and Docker commands are clearly marked.

Produces. Tables, fingerprints, manifests, and verification output.

Source size. The default cells generate approximately 1,000 logical payments; increase duration and population together for a 10,000-payment run.

Offline path. All marked offline cells run without Docker or network services. Service cells are optional and explicitly marked in notebook metadata.

Cleanup. Outputs are written under a temporary directory; remove any local run directory if you changed the output location.

%pip install "confluent-kafka[schemaregistry]" httpx2
Collecting httpx2
  Downloading httpx2-2.13.1-py3-none-any.whl.metadata (9.8 kB)
Requirement already satisfied: confluent-kafka[schemaregistry] in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (2.15.1)
Requirement already satisfied: attrs>=21.2.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from confluent-kafka[schemaregistry]) (26.1.0)
Requirement already satisfied: cachetools>=5.5.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from confluent-kafka[schemaregistry]) (7.2.0)
Requirement already satisfied: certifi in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from confluent-kafka[schemaregistry]) (2026.7.22)
Requirement already satisfied: httpx>=0.26 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from confluent-kafka[schemaregistry]) (0.28.1)
Requirement already satisfied: authlib>=1.0.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from confluent-kafka[schemaregistry]) (1.8.0)
Requirement already satisfied: anyio>=4.10 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx2) (4.15.1)
Collecting httpcore2==2.13.1 (from httpx2)
  Downloading httpcore2-2.13.1-py3-none-any.whl.metadata (26 kB)
Requirement already satisfied: idna>=3.18 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx2) (3.19)
Collecting truststore>=0.10 (from httpx2)
  Downloading truststore-0.10.4-py3-none-any.whl.metadata (4.4 kB)
Requirement already satisfied: h11>=0.16 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpcore2==2.13.1->httpx2) (0.16.0)
Requirement already satisfied: typing_extensions>=4.16.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from anyio>=4.10->httpx2) (4.16.0)
Requirement already satisfied: cryptography>=45.0.1 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from authlib>=1.0.0->confluent-kafka[schemaregistry]) (50.0.1)
Requirement already satisfied: joserfc>=1.6.1 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from authlib>=1.0.0->confluent-kafka[schemaregistry]) (1.7.5)
Requirement already satisfied: cffi>=2.0.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from cryptography>=45.0.1->authlib>=1.0.0->confluent-kafka[schemaregistry]) (2.1.1)
Requirement already satisfied: pycparser in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from cffi>=2.0.0->cryptography>=45.0.1->authlib>=1.0.0->confluent-kafka[schemaregistry]) (3.0)
Requirement already satisfied: httpcore==1.* in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx>=0.26->confluent-kafka[schemaregistry]) (1.0.9)
Downloading httpx2-2.13.1-py3-none-any.whl (95 kB)
Downloading httpcore2-2.13.1-py3-none-any.whl (83 kB)
Downloading truststore-0.10.4-py3-none-any.whl (18 kB)
Installing collected packages: truststore, httpcore2, httpx2
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3/3 [httpx2]━━━━━━━━━━━ 2/3 [httpx2]
Successfully installed httpcore2-2.13.1 httpx2-2.13.1 truststore-0.10.4
Note: you may need to restart the kernel to use updated packages.

Optional Kafka setup#

The first cell installs the Kafka client and Schema Registry support. Start Kafka and Schema Registry only for broker-backed recovery; outage injection and analysis also run offline.

Run the next Python cell from this notebook. It starts the required Docker services, finds the repository’s docker-compose.yml, and waits for Kafka, the payment topic, and Schema Registry. You do not need to paste a Docker command into a separate terminal.

The notebook uses localhost:9092 and http://localhost:8081 when it runs on the host. To stop the services later, run the cleanup cell near the end of the notebook. If the repository cannot be located automatically, set FRAUDTWIN_REPO_ROOT to the project directory before running the setup cell.

import os
import subprocess
import time
from pathlib import Path
from urllib.request import urlopen

repo_root = os.environ.get("FRAUDTWIN_REPO_ROOT")
candidates = ([Path(repo_root)] if repo_root else []) + [Path.cwd(), *Path.cwd().parents]
root = next(
    (path.resolve() for path in candidates if (path / "docker-compose.yml").is_file()),
    None,
)
if root is None:
    raise RuntimeError(
        "Could not find docker-compose.yml. Start Jupyter from the FraudTwin repository or "
        "set FRAUDTWIN_REPO_ROOT to its path."
    )

compose_env = os.environ.copy()
compose_env.setdefault("GRAFANA_ADMIN_PASSWORD", "fraudtwin-local")

compose_command = [
    "docker",
    "compose",
    "--profile",
    "streaming",
    "up",
    "-d",
    "kafka",
    "schema-registry",
    "streaming-topics",
]
subprocess.run(
    compose_command,
    check=True,
    cwd=root,
    env=compose_env,
)

for _ in range(30):
    try:
        with urlopen("http://localhost:8081/subjects", timeout=2) as response:
            if response.status == 200:
                break
    except OSError:
        pass
    time.sleep(2)
else:
    raise RuntimeError("Schema Registry did not become ready; inspect docker compose logs")

expected_topic = "fraudsim.payment.events.v1"
for _ in range(30):
    try:
        topics = subprocess.run(
            [
                "docker",
                "compose",
                "--profile",
                "streaming",
                "exec",
                "-T",
                "kafka",
                "kafka-topics",
                "--bootstrap-server",
                "kafka:29092",
                "--list",
            ],
            check=True,
            capture_output=True,
            text=True,
            cwd=root,
            env=compose_env,
        ).stdout.splitlines()
        if expected_topic in topics:
            break
    except (OSError, subprocess.CalledProcessError):
        pass
    time.sleep(2)
else:
    raise RuntimeError("Kafka did not become ready; inspect docker compose logs")

running_services = subprocess.run(
    [
        "docker",
        "compose",
        "--profile",
        "streaming",
        "ps",
        "--services",
        "--filter",
        "status=running",
    ],
    check=True,
    capture_output=True,
    text=True,
    cwd=root,
    env=compose_env,
).stdout.splitlines()
print(
    {
        "running_services": running_services,
        "schema_registry": "ready",
        "payment_topic": expected_topic,
    }
)
 Container fraudtwin-kafka-1 Running 
 Container fraudtwin-schema-registry-1 Running 
 Container fraudtwin-streaming-topics-1 Starting 
 Container fraudtwin-streaming-topics-1 Started 
{'running_services': ['grafana', 'iceberg-rest', 'kafka', 'minio', 'prometheus', 'schema-registry', 'streaming-topics'], 'schema_registry': 'ready', 'payment_topic': 'fraudsim.payment.events.v1'}

The setup cell must report schema_registry: ready and payment_topic: fraudsim.payment.events.v1 before the live publication check. A connected: True result in the next integration cell confirms that records were published through the broker.

Set up a deterministic source run

import json
import os
from pathlib import Path

import polars as pl

from fraudtwin.config import load_config
from fraudtwin.generation import generate

repo_root = os.environ.get("FRAUDTWIN_REPO_ROOT")
candidates = ([Path(repo_root)] if repo_root else []) + [Path.cwd(), *Path.cwd().parents]
root = next(
    (p.resolve() for p in candidates if (p / "configs" / "minimal.yaml").exists()),
    None,
)
if root is None:
    raise RuntimeError(
        "Could not find configs/minimal.yaml. Set FRAUDTWIN_REPO_ROOT to the project path."
    )
base = load_config(root / "configs" / "minimal.yaml")
# Scale the population so the bounded example produces about 1,000 payments.
population = base.population.model_copy(
    update={
        "customers": 200,
        "accounts": 300,
        "cards": 240,
        "devices": 240,
        "pix_keys": 160,
        "merchants": 60,
    }
)
simulation = base.simulation.model_copy(update={"duration_days": 10})
fraud = base.fraud.model_copy(update={"enabled": True, "target_rate": 0.05})
config = base.model_copy(
    update={"population": population, "simulation": simulation, "fraud": fraud}
)
data = generate(config, write=False)
run_id = data.run_id
payments = pl.DataFrame([item.model_dump(mode="json") for item in data.behavior.payments])
print({"run_id": run_id, "payments": len(payments), "events": len(data.behavior.payment_events)})
{'run_id': 'RUN-2a3ad02ee370aeb8', 'payments': 1092, 'events': 4699}

Inspect schema, grain, and counts

from fraudtwin.kafka import publication_records
from fraudtwin.kafka_chaos import KafkaChaosConfig, KafkaChaosOutage, simulate_delivery

records = publication_records(data.behavior, run_id)[:80]
print({"sent": len(records), "topics": sorted({r.topic for r in records})})
{'sent': 80, 'topics': ['fraudsim.payment.events.v1']}

Run the core operation

chaos_config = KafkaChaosConfig(
    seed=11,
    drop_probability=0.03,
    duplicate_probability=0.08,
    retry_probability=0.1,
    max_delay_seconds=60,
    reorder_window=10,
    partition_count=3,
    partition_skew_probability=0.2,
)
result = simulate_delivery(records, chaos_config)
print(result.manifest["counts"])
{'sent': 80, 'acknowledged': 89, 'dropped': 7, 'retried': 8, 'duplicated': 8, 'late': 84, 'reordered': 47, 'deduplicated': 16}

Measure and interpret the result

outage = KafkaChaosOutage(
    from_time=records[0].observable_time,
    to_time=records[-1].observable_time,
    behavior="BUFFER_AND_FLUSH",
)
outage_result = simulate_delivery(records, chaos_config.model_copy(update={"outages": (outage,)}))
print("outage counts:", outage_result.manifest["counts"])
outage counts: {'sent': 80, 'acknowledged': 89, 'dropped': 7, 'retried': 8, 'duplicated': 8, 'late': 88, 'reordered': 47, 'deduplicated': 16}

Exercise a parameter or failure mode

deduped = {(e.subject, e.record_id): e for e in result.envelopes}
print({"delivered": len(result.envelopes), "deduplicated": len(deduped), "late": result.late_count})
{'delivered': 89, 'deduplicated': 73, 'late': 84}

Write a compact artifact and fingerprint

assert all(e.record_id for e in deduped.values())
print("stable event identity enables safe consumer deduplication")
stable event identity enables safe consumer deduplication

Verify invariants and clean up

display(pl.DataFrame(result.audit[:12]))
shape: (7, 2)
record_idfault
strstr
"EVT-HN-F03-000003-SIGNAL-01""drop"
"EVT-F01-000001-000001-02""drop"
"EVT-HN-F05-000005-000001-02""drop"
"EVT-HN-F05-000005-000002-02""drop"
"EVT-HN-F04-000004-000001-05""drop"
"EVT-HN-F05-000005-000004-02""drop"
"EVT-HN-F05-000005-000004-03""drop"

Optional: publish the run to Kafka#

The offline cells above exercise deterministic loss, retry, duplication, delay, reordering, and deduplication without a broker. Run the live publication cell only after the setup cell reports that Kafka, Schema Registry, and the payment topic are ready. A successful broker-backed check prints connected: True and publication counts; otherwise the cell reports the reason and the offline path remains valid.

Watermarks must account for delayed/out-of-order event time; physical packet loss is outside this logical harness. The outage simulation models the delivery semantics that consumers must handle after transport recovers.

Inspect the generated run#

# A compact inspection is more useful than printing an entire run.
sample_columns = [
    c for c in ("payment_id", "amount", "initiated_at", "payer_account_id") if c in payments.columns
]
sample_rows = payments.select(sample_columns).head(8).to_dicts()
print(f"Sample payments ({len(sample_rows)} of {payments.height} rows):")
for row in sample_rows:
    print(
        f"  - {row.get('payment_id')}: amount={row.get('amount')}, "
        f"initiated_at={row.get('initiated_at')}, payer={row.get('payer_account_id')}"
    )
nulls = {name: count for name, count in payments.null_count().to_dicts()[0].items() if count}
print("\nData quality summary:")
print(f"  rows: {payments.height}")
print(f"  columns: {payments.width}")
if not nulls:
    print("  nulls: none")
else:
    print("  columns with nulls:")
    for name, count in sorted(nulls.items()):
        print(f"    - {name}: {count}")
Sample payments (8 of 1092 rows):
  - PAY-00000001: amount=70.7, initiated_at=2026-01-03T16:25:00Z, payer=ACC-000123
  - PAY-00000002: amount=25.52, initiated_at=2026-01-05T11:37:00Z, payer=ACC-000174
  - PAY-00000003: amount=18.37, initiated_at=2026-01-05T11:21:00Z, payer=ACC-000174
  - PAY-00000004: amount=5.54, initiated_at=2026-01-02T22:30:00Z, payer=ACC-000003
  - PAY-00000005: amount=13.71, initiated_at=2026-01-02T09:41:00Z, payer=ACC-000029
  - PAY-00000006: amount=70.06, initiated_at=2026-01-04T10:40:00Z, payer=ACC-000179
  - PAY-00000007: amount=42.73, initiated_at=2026-01-02T18:04:00Z, payer=ACC-000247
  - PAY-00000008: amount=36.42, initiated_at=2026-01-05T09:27:00Z, payer=ACC-000255

Data quality summary:
  rows: 1092
  columns: 15
  columns with nulls:
    - card_id: 565
    - merchant_id: 565
    - payee_account_id: 86
    - payee_institution_id: 86
    - payee_pix_key_id: 857
    - payer_institution_id: 86
    - payer_pix_key_id: 857

Confirm the generated run#

summary = {
    "run_id": run_id,
    "payments": len(data.behavior.payments),
    "payment_events": len(data.behavior.payment_events),
    "fraud_records": len(data.behavior.fraud_records),
}
assert summary["payments"] == len(payments)
assert summary["payments"] > 0
print(json.dumps(summary, indent=2, default=str))
{
  "run_id": "RUN-2a3ad02ee370aeb8",
  "payments": 1092,
  "payment_events": 4699,
  "fraud_records": 51
}

Publish to a live Kafka broker (optional)#

import os

os.environ.setdefault("FRAUDTWIN_KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")
os.environ.setdefault("FRAUDTWIN_SCHEMA_REGISTRY_URL", "http://localhost:8081")

try:
    from confluent_kafka.admin import AdminClient

    from fraudtwin.kafka import publisher_from_environment

    admin = AdminClient(
        {"bootstrap.servers": os.getenv("FRAUDTWIN_KAFKA_BOOTSTRAP_SERVERS", "localhost:9092")}
    )
    topics = sorted(admin.list_topics(timeout=3).topics)
    published = publisher_from_environment().publish(data.behavior, run_id)
    print({"connected": True, "topics": topics, "published": published.record_counts})
except Exception as exc:
    print(
        {
            "connected": False,
            "offline_fallback": True,
            "reason": type(exc).__name__,
            "detail": str(exc),
        }
    )
{'connected': True, 'topics': ['__consumer_offsets', '_schemas', 'fraudsim.customer.disputes.v1', 'fraudsim.fraud.alerts.v1', 'fraudsim.fraud.case-confirmations.v1', 'fraudsim.fraud.cases.v1', 'fraudsim.fraud.labels.v1', 'fraudsim.payment.events.v1'], 'published': {'payment-event': 4699, 'customer-dispute': 46, 'fraud-alert': 51, 'fraud-case': 51, 'fraud-case-confirmation': 51, 'fraud-label': 51}}

Compare with offline recovery#

This final cell records the deterministic recovery result from the logical outage simulation. It does not require Kafka and remains reproducible when the optional services are stopped.

assert result.output_fingerprint
print({"offline_fallback": True, "recovered": len(deduped)})
{'offline_fallback': True, 'recovered': 73}