Skip to content
← All projects
Data Engineering Active ★ Featured Aug 2026

EuroStream

GDPR-native streaming and medallion lakehouse for European commerce: real-time fraud scoring, DuckDB and Turso serving, and a six-layer Article 17 erasure cascade with a verifiable audit trail.

EuroStream
data-engineeringgdprkafkaduckdblakehousestreamingpython
erasure cascade
6 layers
mean erasure
66.95 ms
p95 erasure
109.2 ms
statutory SLA
60 s
fraud rules
3
test suite
59 tests
type check
mypy strict

EuroStream pipeline: EU commerce events feed an event bus, a real-time fraud engine scores payments, a medallion lakehouse with quality gates serves DuckDB and Turso, and a six-layer Article 17 erasure cascade wipes a customer across every tier.

The whole system on one page: ingest, score, transform, serve and erase. Open the image for the full-size animated version.

EuroStream started from a conflict we kept running into in European data work. Message logs append forever and lakehouse files are immutable by design, while GDPR Article 17 says a person can ask for their data to be erased. Bolting a delete endpoint onto that stack leaves copies behind in partitions, aggregates and in-memory state.

So we built the pipeline around erasure instead. A deletion request runs as an atomic six-layer transaction, and a verification endpoint proves what remains: zero rows in Silver and Gold, zero clear-text PII in Bronze, and one audit entry carrying a truncated hash.

The problem in one paragraph

Kafka partitions, Parquet files and materialized aggregates are append-only by nature. Erasing one person means rewriting history across every layer, and the usual fix is a manual script that misses a copy somewhere. A missed copy can mean a fine under Article 83(5) and a public incident, so deletion here is a pipeline operation with tests, not a support ticket.

How it works

EuroStream end-to-end architecture: events flow from producers through the bus and fraud engine into the medallion warehouse, the Parquet lake and the API.

  1. A producer writes synthetic EU orders, clicks and payments onto the bus. Locally that is SQLite in WAL mode; in production it is Aiven Kafka over SASL_SSL with SCRAM-SHA-256.
  2. The streaming engine consumes payments and scores them against three rules: a velocity spike (more than 5 payments in 300 seconds), an amount z-score above 3.0 computed with sample variance, and a billing-to-merchant country mismatch. Suppressed customers are dropped before any rule runs.
  3. Every four hours the batch DAG runs watermarked incremental merges. Bronze keeps raw events, Silver deduplicates on natural keys and replaces PII with salted SHA-256 pseudonyms, and Gold builds the consent-gated marts.
  4. Six quality assertions run after the merge, including uniqueness on both marts, a check that no clear-text email or IBAN pattern survives in Silver, and consent gating against the upstream flag.
  5. Changed Silver and Gold tables export to de-identified Parquet and sync to the Hugging Face dataset. Only files whose content hash changed are pushed.
  6. The FastAPI app reads the local DuckDB file during development or the Turso cloud replica in production, and serves the dashboard, the REST API and the Prometheus exposition.

The six-layer erasure cascade

Every layer that could hold a copy of the customer gets touched in one ordered transaction:

  1. Suppression registry. The customer id enters an in-memory set and the governance.suppression_registry table, so replayed or delayed events are dropped before they reach a consumer.
  2. Bronze anonymization. Raw rows are masked in place (email, iban, ip become <anonymized>) so ledger ordering and row counts survive.
  3. Silver hard delete. Pseudonymized customer, order and payment rows are removed.
  4. Gold hard delete. The Customer 360 aggregate, order facts and fraud summary rows are removed, so no ghost record keeps lifetime spend.
  5. Streaming memory purge. The fraud scorer evicts the customer's window state and alert history, then their Bronze alerts are deleted.
  6. Lake re-snapshot. Silver and Gold Parquet partitions are rewritten and synced, and the audit log stores sha256(request_id : customer_id)[0:16].

The verification endpoint returns the counts a regulator would ask for:

{
  "customer_id": "cust_424242",
  "verified": true,
  "is_suppressed": true,
  "gold_rows_remaining": 0,
  "silver_rows_remaining": 0,
  "bronze_clear_text_rows": 0,
  "bronze_anonymized_rows": 60,
  "audit_log_entries": 1
}

The benchmark suite runs 50 erasures end to end: 66.95 ms mean, 61.84 ms median, 109.20 ms p95 and 110.07 ms worst case. The statutory window is 60 seconds, so the slowest run finished with plenty of room, and erasure_sla_breaches_total stays at 0.

Three fraud rules and one gate

  • Velocity. A customer with more than 5 payments inside a 300 second window gets one alert per tumbling window, which keeps a burst from becoming an alert flood.
  • Amount z-score. Each payment is compared against the customer's own history with sample standard deviation, and the score has to pass 3.0. The history is a bounded deque of 200 amounts with a sweep every 50 events, so memory cannot grow without limit.
  • Geo mismatch. The issuing bank country and the merchant country are compared directly, which catches card testing that stays under the amount threshold.
  • Suppression gate. Every rule checks the suppression registry first. An erased customer cannot produce an alert, and their state is gone from memory as well as from storage.

Governance that runs in CI

The PII classifier does not trust patterns alone. European IBANs are validated with the ISO 13616 / ISO 7064 mod-97 checksum, which stops the false positives that regex matching produces on UUIDs and long order ids. An early version of this check flagged valid UUIDs, and the repository keeps the postmortem for it.

Schema drift is handled the same way. eurostream contracts --baseline governance/contracts.json compares the live models against a committed baseline, and CI blocks any pull request that adds an unclassified column or breaks an existing contract. The rest of the gate is ruff, mypy in strict mode across 23 source files, and the pytest suite.

Local now, cloud later

Every component sits behind one interface, so the same code runs on a laptop and in production:

  • Event bus. SqliteBus locally, KafkaBus on Aiven when EUROSTREAM_EVENT_BUS_BACKEND=kafka.
  • Warehouse. Embedded DuckDB for analytics, with every write mirrored to a Turso libSQL replica over HTTP.
  • Lake. Parquet under data/lake/, synced to the swadhinbiswas/eustream dataset on Hugging Face.
  • API. Uvicorn on port 7860 locally, a Docker container on Render in production.
  • Orchestration. The CLI or cron locally, a scheduled GitHub Actions DAG every four hours in production.

The dual-engine setup is what keeps governance state alive. Containers restart, but suppression sets and watermarks live in DuckDB and Turso, so a fresh process reconstitutes them on boot instead of losing them with the container.

Observability

The app exposes Prometheus counters and a latency summary at /metrics/prometheus, which is enough for Grafana to scrape erasure throughput, SLA breaches and fraud alerts split by rule. The dashboard also has five live views: overview, fraud intelligence, Medallion and Customer 360, the Article 17 console, and a Prometheus explorer.

Stack

Part Technology
Language Python 3.11+, mypy strict
Event bus SQLite WAL, Aiven Kafka
Warehouse DuckDB, Turso libSQL
Lake Parquet on Hugging Face
API FastAPI, Uvicorn
Orchestration GitHub Actions, 4-hour cron
Deploy Docker, Render
Quality pytest, ruff, contract gate

The hard part

What made it hard

Erasure and immutability pull in opposite directions

Kafka wants append-only partitions, Parquet wants immutable files, and Article 17 wants a person gone. Rewriting topic partitions is not an option, so the cascade works with the grain of each layer instead: Bronze rows are masked in place to keep ledger ordering intact, Silver and Gold rows are hard deleted, the fraud scorer's in-memory state is evicted, and the public lake partitions are rewritten from the corrected tables. A suppression registry catches replayed and delayed events before they can resurrect the customer.

Ghost records hide in the aggregates

The first working version deleted from Silver and stopped there. gold.customer_360 still carried lifetime spend, order counts and marketing flags, which is exactly the kind of copy an audit finds. The verification endpoint now counts rows across every tier, so a missed layer fails a test instead of surfacing in a data subject response.

The first IBAN check flagged UUIDs

Pattern-only PII detection marked long hex strings as bank accounts and blocked valid events. The classifier now runs the ISO 13616 / ISO 7064 mod-97 checksum on candidates, which keeps the catch rate on real IBANs and removes the false positives. The repository keeps the postmortem for the incident, including the test that reproduces it.

In-memory state does not survive a container restart

Suppression sets started as a process-local set. On Render, a restart quietly cleared them and replayed events could write data for an erased customer. Suppression now lives in the DuckDB warehouse and the Turso replica, and the in-memory set is a cache that rebuilds from durable state on boot.

A drifting column should fail the build

Compliance work breaks when someone adds delivery_notes to an event without classifying it. The contract baseline gate compares every model against a committed schema file in CI, and mypy strict runs over the pipeline so a wrong type in the erasure path fails before a deploy. Both gates exist because the first version of the cascade had a silent type mismatch between the Bronze mask and its verification query.

Outcome

What exists today

  • One command runs the whole story end to end: produce events, score fraud, transform the medallion layers, erase a customer and verify the result
  • The six-layer cascade averages 66.95 ms per erasure over 50 benchmark iterations, with a worst case of 110.07 ms against a 60 second statutory window
  • 59 tests cover the IBAN checksum, z-score bounds, suppression gating, cascade integrity, watermark advances and poison-pill handling
  • mypy runs strict across 23 source files, ruff is clean, and the contract baseline gate blocks unclassified schema changes
  • A public, de-identified Parquet lake on Hugging Face with the Silver and Gold tables
  • A FastAPI app with five live views, including the Article 17 console that visualizes the cascade
  • An architecture RFC, three ADRs, a documented erasure flow and an incident postmortem in the repository
  • A JOSS manuscript draft describing the design and the benchmark methodology

What I'd do differently

If we built it again

  • Treat erasure as a transaction from the first commit. The suppression registry and the verification endpoint arrived after the ghost-record bug, when they should have been part of the schema.
  • Write the verification query before the deletion code. Deleting rows is easy to write and hard to prove, and the counts across six layers are what actually make the cascade trustworthy.
  • Keep governance state out of the process from day one. Moving suppression sets into DuckDB and Turso fixed a real failure mode rather than an imagined one.
  • Put the contract baseline gate next to the models it checks. It started as a separate script that drifted from the schema, which defeats the point of a gate.
  • Run the cloud engines from the beginning of development. Dual-writing to Turso every cycle found synchronization bugs that a local-only workflow would have hidden until deployment.