PacketShard — protocol-sharded packet pipeline: Kafka + outbox, Akka.NET routing, at-least-once delivery (offset commit after DB write).
Read side: Debezium CDC streams shard writes back through Kafka into an idempotent Postgres projection (pg_ivm live stats), with Redis fast-path dedup — at-least-once in, exactly-once projected.
High availability (optional): the MySQL outbox tier runs as a semi-synchronous primary/replica pair behind ProxySQL, so an acknowledged packet already exists on two nodes before the client call returns — switched on with one line in
.env.
Service discovery: services register themselves with Consul and the gateway resolves its backends from the catalog, so scaling a tier is a Compose change and a failed instance leaves the rotation on its own health check.
Microservice pipeline: packets enter over gRPC through a load balancer, flow through a MySQL outbox → Kafka → Akka.NET MasterNode → 5 MongoDB shards write path, and are projected by CDC (Debezium) into a Postgres read model (pg_ivm) — a CQRS split with the protocol shards as the write side and Postgres as the query side. End-to-end the pipeline guarantees at-least-once delivery: a Kafka offset is committed only after the packet has been durably written to its shard, and the read-side projection turns at-least-once input into an exactly-once result.
Contents: Architecture · Akka.NET routing · At-least-once delivery · Projects · CQRS read side · Scaling · Service discovery · AWS · High availability · Retries & dead-letter · Run it · Tests
The diagram below illustrates the complete data flow, from packet ingress to the read model. Two invariants hold at every hop:
- Durability before acknowledgment — a stage never confirms a packet until the next durable store (MySQL outbox, Kafka topic, MongoDB shard, Postgres ledger) has accepted it.
- At-least-once delivery —
srv_subcommits its Kafka offset only after the MasterNode confirms the shard write with"Ok"(offset commit after DB write). A crash at any point causes redelivery, never loss.
┌────────────────────────┐
│ PacketGeneratorConsole │
│ PacketGeneratorClient │
└────────────────────────┘
│ gRPC (HTTP/2)
▼
┌────────────────────────┐ "which are healthy?" ┌────────────────────────┐
│ LoadBalancer (YARP) │ ────────────────────▶ │ Consul :8500 │
│ :5001 gRPC · :5002 REST│ ◀──────────────────── │ catalog + health │
└────────────────────────┘ instance list └────────────────────────┘
│ round-robin ▲
▼ │ register
┌────────────────────────┐ │ + pass /health
│ srv_ingest × 3 │ ──────────────────────────────────┘
│ (gRPC, write) │
└────────────────────────┘
│ tx insert (durable before ack)
▼
┌────────────────────────┐ poll (SKIP LOCKED) ┌───────────┐
│ MySQL Outbox │ ◀───────────────────── │ srv_pub │
│ (durable Q) │ │ (relay) │
└────────────────────────┘ └───────────┘
▲ one node by default; a primary/replica pair behind ProxySQL
when the HA overlay is on — see "High availability" below
│ publish (no dual-write)
▼
┌────────────────────────┐
│ Kafka SnapshotTopic │
│ (5 partitions) │
└────────────────────────┘
│ consume
▼
┌────────────────────────┐
│ srv_sub × 5 │ ─── commit offset ◀── ONLY on "Ok"
│ (1 per partition) │ (at-least-once: commit AFTER DB write)
└────────────────────────┘
│ forward payload (TCP) ▲
▼ │ "Ok" after shard insert
┌────────────────────────┐ │
│ MasterNode (Akka.NET) │ ─────────────────┘
│ auth · filter · route │──▶ rejected ✗ → retry ×3 → deadletter
└────────────────────────┘
│ insert (proto-routed)
▼
┌────────────────────────────────────┐
│ 5 MongoDB shards (write side, rs0)│
│ HTTPS │ TCP │ UDP │ ARP │ OTHER │
│ :27018 – :27022 │
└────────────────────────────────────┘
│ change streams (CDC)
▼
┌────────────────────────────┐
│ Debezium × 5 │
│ (MongoDB connector) │
└────────────────────────────┘
│ pcap.<shard>.packets
▼
┌────────────────────────────┐
│ Kafka │
│ (pcap.* topics) │
└────────────────────────────┘
│ consume (at-least-once in)
▼
┌────────────────────────────┐ INSERT ON CONFLICT ┌───────────────────┐
│ srv_read │ ────────────────────▶ │ Postgres + pg_ivm │
│ (CDC consumer + API) │ ② commit first │ packet_ledger │
└──────────┬─────────────────┘ │ UNIQUE(tx_id) │
│ │ packet_stats_by_ │
│ ① check ③ mark (after commit) │ proto (IMMV) │
▼ └───────────────────┘
┌────────────────────────────┐ ▲ SELECT
│ Redis │ │ (no agg)
│ (fast-path filter) │ ◀────────────────────────────┘
└────────────────────────────┘ web client (GET /stats/*)
④ Kafka offset commit — always last
Key stages:
- gRPC ingress: clients stream packets over gRPC (HTTP/2) to a YARP load balancer, which
round-robins them across
srv_ingestinstances. Each instance writes the packet to the MySQL outbox inside a transaction — durable before the client call returns. The gateway learns which instances exist from Consul rather than from a list it has to be told about (see Service discovery). - Outbox relay:
srv_pubpolls the outbox withFOR UPDATE SKIP LOCKEDand publishes to Kafka, eliminating the dual-write problem: nothing is lost if Kafka is down. - Akka.NET routing: the MasterNode is an Akka.NET actor system that authenticates, filters by protocol, and routes each packet to one of 5 MongoDB shard nodes (see Akka.NET routing).
- CQRS read side: Debezium captures changes from the MongoDB shards and streams them via
Kafka to
srv_read, which projects the data into a Postgres read model (pg_ivm), with Redis as a fast-path duplicate filter.
Why an actor system — isolation, lock-free concurrency, and an explicit backpressure point
The MasterNode is not a monolithic handler — it is an Akka.NET actor system exposed as a TCP server. Every inbound connection and every shard destination is an actor, which gives the routing stage three properties for free:
- Isolation — a malformed packet or a failing shard crashes one actor, not the process; the supervision strategy restarts it while the rest of the pipeline keeps flowing.
- Lock-free concurrency — actors process one message at a time from their mailbox, so the
auth → filter → route sequence needs no shared-state locking even with 5
srv_subreplicas pushing packets concurrently. - Explicit backpressure point — the
"Ok"reply is generated only after the shard actor’s insert succeeds, which is exactly the signalsrv_subwaits for before committing its offset.
Message flow — TCP listener → connection handler → filter → 5 shard actors, and the “Ok” back
TCP (from srv_sub × 5)
│
▼
┌──────────────────────────┐
│ Akka.IO TCP listener │ accepts connections,
│ (server actor) │ one handler per socket
└────────────┬─────────────┘
│ Received(payload)
▼
┌──────────────────────────┐
│ Connection handler │ deserialize PacketMessage,
│ (per-connection) │ authenticate API key (hash)
└────────────┬─────────────┘
│ authenticated ✓ ✗ auth fail → reject
▼
┌──────────────────────────┐
│ Protocol filter │ inspect packet `proto`,
│ + router │ pick target shard
└────────────┬─────────────┘
│ route by protocol
┌─────┬───┴──┬──────┬───────┐
▼ ▼ ▼ ▼ ▼
┌─────┐┌─────┐┌─────┐┌─────┐┌───────┐
│HTTPS││ TCP ││ UDP ││ ARP ││ OTHER │ 5 ShardNode actors —
│shard││shard││shard││shard││ shard │ each owns one MongoDB
└──┬──┘└──┬──┘└──┬──┘└──┬──┘└───┬───┘ connection, inserts doc
└──────┴──────┴──────┴───────┘
│ insert OK
▼
reply "Ok" ──▶ back to srv_sub ──▶ _consumer.Commit(result)
The reply path is the heart of the delivery guarantee: “Ok” flows backwards from the shard insert to the Kafka commit, so the offset moves only when the data is already on disk in Mongo.
When something fails — which failures are reported, which are retried, and which burn an attempt
The routing stage distinguishes a packet that cannot be stored from infrastructure that is momentarily unavailable. Confusing the two either quarantines good packets or retries a poison one forever, so each failure has a defined answer:
| What fails | What the MasterNode does | What srv_sub does |
|---|---|---|
| A shard's MongoDB node is down | ShardWriterActor catches the write error and replies Write failed: … — the actor stays alive |
treats it as a rejection: counts an attempt, re-queues, dead-letters after 3 |
| Invalid API key | replies Invalid API Key and closes the connection |
the connection drops; nothing is routed |
| Malformed payload | proto cannot be parsed, so it routes to the OTHER shard rather than dropping the packet |
normal path — it is stored, not lost |
| A connection handler actor crashes | only that socket's actor dies; the listener and the five shard actors are untouched | reconnects on the next message |
| The whole MasterNode is down | nothing to reply | SendAsync returns null — rewind the offset, wait 2s, retry. Not counted as an attempt |
That last row is the important asymmetry. An unreachable MasterNode is a transport failure and the packet is still good, so retrying forever is correct. An explicit rejection is a processing failure, so it is counted and eventually quarantined. See Retries & dead-letter.
Two details worth stating precisely, because the actor model is often assumed to do more than it does here:
- The shard writers do not actually crash on a failed write.
ShardWriterActorcatches the exception and answers with an error string, so no supervision restart occurs on the Mongo path — isolation shows up as the other four shards keep working, not as a restart. A shard that is down produces rejections, not an outage. - There is no custom
SupervisorStrategy. Anything that does throw gets Akka's default one-for-one restart, which is the right behaviour for a per-connection handler and is why one bad socket cannot take the listener with it.
The write path's own database tier has a separate failover story — see High availability.
How the guarantee is built — a transactional outbox on the producer side, commit-after-write on the consumer
Two mechanisms combine into an end-to-end at-least-once guarantee, one on each side of Kafka:
Producer side — transactional outbox. srv_ingest never talks to Kafka directly. It writes
each packet into the MySQL Outbox table inside the same DB transaction as its business write
(IOutbox.AddAsync). The PublishOutboxJob relay then polls the table (concurrency-safe
FOR UPDATE SKIP LOCKED reservation), publishes reserved rows to the SnapshotTopic Kafka topic
via KafkaMessagePub, and marks them processed; CleanupOutboxJob deletes processed rows. This
removes the dual-write problem: if Kafka is down, packets simply wait in MySQL.
Consumer side — commit after DB write. srv_sub consumes the topic and forwards each
packet’s payload over a TCP connection to the MasterNode. It commits the Kafka offset only
when the MasterNode replies "Ok" — i.e. only after the packet is durably inserted into its
MongoDB shard. This is the processed → _consumer.Commit() pattern:
var processed = await _forwarder.SendAsync(envelope.Payload, stoppingToken);
if (processed) _consumer.Commit(result);Failure analysis — what happens when a component dies mid-flight
| Crash point | Outcome |
|---|---|
| after outbox insert, before Kafka publish | relay re-reserves the row on restart → published later, nothing lost |
| after Kafka publish, before offset commit | packet redelivered to srv_sub → forwarded again (at-least-once) |
| after shard insert, before offset commit | packet redelivered → duplicate insert absorbed by read-side dedup |
| after Postgres commit, before Redis mark | redelivery re-INSERTs → ON CONFLICT DO NOTHING absorbs it, no double count |
Every row above is an executable test rather than a claim — see Tests.
Why duplicates are expected — and why that makes the read side idempotent
Duplicates are therefore possible by design — and that is exactly why the read side makes its projection idempotent (see CQRS read side).
Project map — what each service and library does
PacketGeneratorConsole/PacketGeneratorClient: gRPC clients that generate randomized test packets and stream them into the system.LoadBalancer: YARP-based API gateway — round-robins gRPC (HTTP/2) traffic across the ingest services and REST traffic tosrv_read, with JWT auth, per-caller rate limiting and optional TLS. Its destinations are written asdiscover://<service>:<port>and resolved from whichever registryDiscovery:Providernames.srv_ingest: gRPC service that receives packets and writes them to the MySQL outbox inside a DB transaction.srv_pub: worker that relays packets from the MySQL outbox to Kafka (FOR UPDATE SKIP LOCKEDreservation, publish, mark processed, cleanup).srv_sub: worker that consumes Kafka, forwards packets over TCP to the MasterNode, and commits the offset only on"Ok"(at-least-once).MasterNode: Akka.NET TCP server — actor pipeline that authenticates the API key, filters each packet byproto, and routes it to the matching MongoDB shard.srv_read: CQRS read-side service.CdcConsumerconsumes CDC events from Debezium/Kafka and hands each toProjectionHandler, which projects it into a Postgres read model (pg_ivm) using Redis as a fast-path duplicate filter. Exposes a read API (GET /stats/*).- Shared libraries:
Shared(PacketMessage,SnapshotMessage,ProtocolType, serializer, API-key hasher),kafka(KafkaMessagePub,TopicRepository, producerMessage),outbox(outbox table,Outbox/Relay, publish + cleanup jobs, MySQL persistence), anddiscovery(IServiceDirectorywith Consul, DNS and static implementations, plus the hosted service that registers a process with Consul and deregisters it on shutdown). PacketShard.Tests: one test project covering all of the above, split into a fast in-process lane and a Testcontainers-backed lane — see Tests.
The 5 shards — one MongoDB instance per “main package” type
| Shard | Protocol(s) | Host port |
|---|---|---|
| 1 | HTTPS / TLS / SSL | 27018 |
| 2 | TCP | 27019 |
| 3 | UDP | 27020 |
| 4 | ARP | 27021 |
| 5 | OTHER (everything else, e.g. ICMP, DNS) | 27022 |
All shards store into database pcap, collection packets.
Outbox notes — MySQL implementation details
- EF provider is Pomelo MySQL; the outbox transaction uses
RepeatableReadisolation. - Outbox
IdisCHAR(36)(aUUID()). - The reservation stored procedure
GetDataFromTempTableis created on startup. - Both
srv_ingestandsrv_pubrunIOutboxInitializer.InitializeAsyncon startup (with retry, since MySQL may still be warming up). It is idempotent, so whichever wins the race is fine. - The connection string is read from
ConnectionStrings:Outbox, falling back toSqlConnStr. That fallback is what lets the HA overlay repoint the apps at ProxySQL without touching the base compose file orappsettings.json.
Why CDC — the shards hold the post-routing truth
The pipeline above is the write side. The read side adds an analytics/query model without touching
it: Debezium captures what actually landed in the Mongo shards (post-routing truth) and streams it
to a dedicated microservice that projects it into Postgres, where a pg_ivm incrementally
maintained view keeps per-protocol summaries live. The full read-side flow — shards → Debezium →
Kafka pcap.* topics → srv_read → Postgres/Redis, with the numbered ①–④ processing order — is
shown in the tail of the main architecture diagram above.
Why CDC and not just a new consumer group on SnapshotTopic? Because the shards hold the post-routing truth — what survived auth → filter → routing. Rejected and dead-lettered packets never reach the shards, so reading the shards (via CDC) summarizes what was actually stored, not what was merely published.
Correctness — at-least-once in, exactly-once projected
Kafka is at-least-once (by design — see
At-least-once delivery), so the projection
is made idempotent. ProjectionHandler (srv_read/ProjectionHandler.cs) applies a deliberate,
crash-safe order to every event; CdcConsumer around it is only Kafka plumbing — subscription,
the consume loop, and when to commit:
- Redis fast-path — a read-only
EXISTS rm:tx:<id>check skips known duplicates before they cost a Postgres round-trip. Redis is a filter, never the source of truth. - Postgres commit — one transaction applies both guards:
- dedup:
INSERT … ON CONFLICT (transaction_id) DO NOTHING(permanent, not TTL-bound); - ordering:
client_stateupsert withWHERE EXCLUDED.version > client_state.version(drops “hello from the past” for last-value views; inert for commutative counts).
- dedup:
- Redis mark —
SET rm:tx:<id>happens only after the commit. - Kafka ack — commit the offset last.
A crash between steps 2 and 3/4 is safe: on redelivery Redis still says “not seen”, the re-INSERT
hits ON CONFLICT DO NOTHING, and nothing is lost or doubled. This is the Postgres-first
order — the only crash-safe one. Reversing steps 2 and 3 would make Redis lie: the fast path would
skip a transaction Postgres never stored, and the packet would vanish without a trace. Note the
symmetry with the write side: both srv_sub and srv_read follow the same principle —
persist first, acknowledge (commit the offset) last.
Splitting the handler out of the consumer is what makes that order testable: it can be driven against a real Postgres and a real Redis with no broker in the loop (see Tests).
The per-protocol summary is a pg_ivm IMMV (postgres/init.sql): count/min/max are
commutative, so a trigger maintains them on every INSERT — no REFRESH, no query-time
aggregation.
How each stage scales — coordination delegated to the datastore or to Kafka
Every stateless stage scales horizontally; coordination is delegated to the datastore or to Kafka instead of app-level locks:
- srv_ingest ×3 behind YARP — ingest instances are stateless; the load balancer round-robins gRPC streams, and durability lives in the shared MySQL outbox. The replica count is not configured on the gateway: instances register themselves with Consul and the gateway resolves them, so a fourth replica joins the rotation on its own.
- srv_pub ×3 — all producers write to the same MySQL outbox; the relay reserves rows with
FOR UPDATE SKIP LOCKED, so the 3 instances never double-publish. - srv_sub ×5 — Kafka gives one consumer per partition per group, so the topic is created
with 5 partitions (
TopicPartitions, set inkafka/TopicRepository.cs) and each of the 5 consumers gets its own partition — parallelism without rebalancing churn. - MasterNode (Akka.NET) — concurrency inside a single process comes from the actor model: one handler actor per connection, one shard actor per MongoDB node, each with its own mailbox.
Replica counts are set in docker-compose.yml via deploy.replicas.
Verify the scaling — commands
docker compose up -d --build # replicas come from deploy.replicas
docker compose ps # srv_pub-1..3, srv_sub-1..5
# proof all 5 consumers are active (5 partitions across 5 CONSUMER-IDs, lag ~0):
docker exec packetshard-kafka kafka-consumer-groups \
--bootstrap-server localhost:9092 --describe --group ConsumerGroup
What it replaced — a gateway that had to be told what exists
The gateway used to name its backends: ingest-1, ingest-2, ingest-3. That list is a second
place the replica count lives, and it drifts — scaling meant editing gateway config and restarting
it, and a dead instance kept its slot until YARP's own probes noticed. Now instances publish
themselves and the gateway reads the registry.
Only the addresses became dynamic. Routes, load-balancing policy, health-check policy and HTTP
version stay in appsettings.json, where they are readable:
"Destinations": {
"ingest": { "Address": "discover://srv-ingest:8080" }
}ServiceDestinationResolver (YARP's IDestinationResolver) expands that into one destination per
live instance — ingest[0], ingest[1], … — and re-resolves when the set changes. Addresses that
are not discover:// pass through untouched, so a cluster can mix discovered and pinned
destinations. A lookup that fails returns the last known instances rather than none: an
unreachable registry is not evidence the backends went away.
Which registry answers is a deployment setting, not an application one:
Discovery:Provider |
Backed by | How a change is noticed | Used by |
|---|---|---|---|
Static (default) |
the Discovery:Fallback map |
it never changes | no infrastructure at all |
Consul |
the agent's health endpoint | blocking query — a parked connection, not a poll | docker-compose.yml |
Dns |
A records, Cloud Map on AWS | a refresh interval; DNS cannot push | aws/ |
Callers depend on IServiceDirectory and never learn which one answered — which is why the AWS
deployment needs no code change, only Discovery__Provider=Dns. The port in the sentinel is there
for Dns: Consul stores each instance's port, but an A record carries an address and nothing else.
Who registers — and the one surprise
| Service | Registers as | Port | Check |
|---|---|---|---|
srv_ingest×3 |
srv-ingest |
8080 | HTTP GET :8081/health |
srv_read |
srv-read |
8080 | HTTP GET :8080/health |
MasterNode |
masternode |
8000 | TCP connect — it speaks a line protocol, not HTTP |
LoadBalancer |
gateway |
5001 | HTTP GET :5002/health |
Registration runs in the background and retries rather than blocking startup, and each check starts
critical so a slow-booting instance never receives traffic it cannot serve. A clean shutdown
deregisters; a kill is swept up by DeregisterCriticalServiceAfter.
The surprise: srv_ingest listens twice. Its service port carries gRPC, which is HTTP/2-only —
plaintext has no ALPN to negotiate with — while Consul's HTTP check speaks HTTP/1.1. So the same
/health is exposed on a second HTTP/1.1 listener (HealthPort, 8081). The gateway and the agent
probe the same endpoint on different ports.
srv_sub resolves the MasterNode the same way when MasterNode__Service is set, rotating instances
on reconnect; leave it unset and the fixed MasterNode__Host is dialled as before.
Verify it — commands
docker compose up -d --build
open http://localhost:8500 # catalog UI
curl -s 'localhost:8500/v1/health/service/srv-ingest?passing=true' \
| jq -r '.[] | "\(.Service.ID) \(.Service.Address):\(.Service.Port)"'
docker compose logs -f loadbalancer | grep "instance(s) of"Add a replica — copy the srv_ingest-3 block, give it its own hostname: (that is what keeps its
service id distinct) — and the gateway picks it up with no config edit and no restart. Kill one and
the reverse happens: the check goes critical, Consul drops it, the gateway re-resolves.
Honest caveats
- One agent, one server.
-bootstrap-expect=1has no quorum, so it is a single point of failure for discovery — though not on the data path: in-flight requests never touch Consul and the last known instances keep serving. A service that starts during an outage cannot register. Production wants 3 or 5. - No ACLs, no TLS. The agent runs open inside the Compose network.
Discovery:Consul:Tokenexists for the ACL case; nothing sets it here. - Consul is not a load balancer. It answers "which instances are healthy"; YARP still decides where each request goes and keeps its own health checks. The overlap is deliberate — Consul catches a dead process, YARP catches one that is reachable but failing requests.
The deployment — the whole pipeline on ECS Fargate, in aws/
aws/ holds Terraform for the entire pipeline on ECS Fargate: NLB → YARP → srv_ingest → RDS MySQL
outbox → Kafka (KRaft on EFS) → srv_sub → MasterNode → 5 Mongo shards → Debezium → srv_read →
Postgres/pg_ivm, with API Gateway fronting GET /stats/* over a VPC Link. Roughly 17 tasks,
about $8–9 a day, and a clean terraform destroy. Full instructions in
aws/README-AWS.md.
.github/workflows/deploy-aws.yml is the delivery half: on a push to main it builds the seven
images, pushes them to ECR and rolls the services, authenticating through GitHub OIDC so there is
no access key in the repo. It never runs Terraform — infrastructure stays a deliberate apply.
It is a hybrid-demo profile on purpose: only the outbox uses a managed service (RDS MySQL);
Kafka, Postgres, Mongo and Redis stay as containers so the project's own images and guarantees —
pg_ivm, rs0 change streams, the partition count — are the ones actually deployed. README-AWS.md
lists the managed upgrade path (MSK, Atlas, ElastiCache) and is honest that terraform validate
has not been run against a real provider registry.
Why it needs no application patch — the provider is the only thing that changes
AWS replaces the three fixed ingest replicas with a Cloud Map service whose multivalue A records list every healthy task. That is a different registry, not a different design, so it lands entirely in configuration:
docker-compose: Discovery__Provider=Consul -> ConsulServiceDirectory
aws/ (ECS): Discovery__Provider=Dns -> DnsServiceDirectory
neither: Discovery__Provider=Static -> StaticServiceDirectory
discover://srv-ingest:8080 in appsettings.json is read the same way in all three. The gateway,
ServiceDestinationResolver and srv_sub are byte-identical between a laptop and the VPC.
Two things are genuinely different on AWS, and both are handled by the provider rather than by the app: nothing registers itself — ECS registers a task with Cloud Map when it starts it, so the registration hosted service is not even added — and watches become polls, since DNS cannot push a change. The port lives in the sentinel because an A record cannot carry one.
Expanding the record into per-task destinations is the part that matters for throughput. Left as a
single DNS name, SocketsHttpHandler picks one address and multiplexes every gRPC stream onto one
task, so YARP's round-robin never sees the rest.
The overlay — semi-sync MySQL + Orchestrator + ProxySQL, switched in one line of .env
The single mysql service is the write path's one hard dependency: if it is down, srv_ingest
cannot accept a packet at all, because the outbox insert is the durability guarantee. ha/
replaces it with a semi-synchronous primary + replica pair, Orchestrator
for failover detection and promotion, and ProxySQL routing the apps to whichever node is currently
writable.
It ships as a compose overlay, so the main docker-compose.yml needs no edits. The mode is one
line in .env:
# HA mode: semi-sync MySQL + Orchestrator + ProxySQL
COMPOSE_FILE=docker-compose.yml:ha/docker-compose.ha.yml
# single-node mode: comment the line out
Everything after that is the usual docker compose up -d --build.
The overlay makes three couplings so the base file stays untouched:
- The old
mysqlis parked, not deleted — compose cannot remove a service during a merge, butprofiles: ["disabled"]means nothing ever starts it. Parking alone is not enough, though: compose pulls a profiled service back in when an active service stilldepends_onit, so the app overrides drop that dependency withdepends_on: !override(compose ≥ 2.24). - The apps wait for a writer.
srv_ingest-1..3andsrv_pubgaindepends_on: ha-bootstrap (service_completed_successfully), so they never start against a node that is stillsuper_read_only. - The connection string is repointed via
ConnectionStrings__Outbox→ ProxySQL on:6033. The base file'sSqlConnStris still present in the merged environment and is simply outranked, which is what makes the toggle symmetric in both directions.
| Component | Role | Port |
|---|---|---|
mysql-master |
semi-sync source; writable only by runtime appointment | — |
mysql-slave |
semi-sync replica, super_read_only until promoted |
— |
proxysql |
routes the app to hostgroup 0 (the writer), follows super_read_only |
6033 (app), 6032 (admin) |
orchestrator |
topology detection, promotion, re-parenting; web UI + API | 3000 |
ha-bootstrap |
one-shot: appoints the initial writer, registers the topology | — |
Data flow through a failover — what happens to in-flight packets when the primary dies
Steady state — every app connection goes to ProxySQL, which keeps exactly one node in the writer
hostgroup and decides which by polling super_read_only:
srv_ingest × 3 ─┐
├──▶ ProxySQL :6033 ──▶ hostgroup 0 ┌──────────────┐
srv_pub (relay)─┘ (writer only) ═══════════▶ │ mysql-master │ read_only = OFF
└──────┬───────┘ (appointed)
hostgroup 1 │ semi-sync AFTER_SYNC
(parked, no traffic)│ commit waits for the
┌──────▼───────┐ replica's ack
│ mysql-slave │ super_read_only = ON
└──────────────┘
The source commits only after a replica has the binlog event, so an acknowledged outbox row exists
on two nodes before srv_ingest returns to the client — the same durability before
acknowledgment invariant the rest of the pipeline follows.
When the primary dies:
① mysql-master gone ② promote ③ ProxySQL re-elects
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ mysql-master │ ✗ │ mysql-slave │ │ mysql-slave │
│ (down) │ │ super_ro=OFF │ │ hostgroup 0 │◀── writes resume
└──────────────┘ └──────────────┘ └──────────────┘
│ ▲ ▲
│ writes fail │ SET GLOBAL (runtime only, │ monitor sees
▼ │ never persisted) │ read_only flip
srv_ingest returns an error ─────┴─────────────────────────────┘
srv_pub's PublishAsync throws → transaction rolls back
Nothing in flight is lost, because every stage already assumes this can happen:
| In flight when the primary dies | What happens |
|---|---|
gRPC call mid-AddAsync |
the insert fails, the client gets an error and retries — the packet was never acknowledged |
| outbox rows reserved but not yet published | PublishAsync throws, the transaction rolls back, IsProcessing clears; the next relay tick re-reserves them |
| rows published to Kafka but not marked processed | the same rows are reserved again after promotion and re-published — Kafka is at-least-once by design, and the read side's ON CONFLICT DO NOTHING absorbs the duplicate |
| rows already marked processed | committed on the old primary and acked by the replica before the commit returned, so they survive the promotion |
The failure mode this design refuses is a second writer. A crashed primary that restarts comes
back super_read_only (persisted in mysqld-auto.cnf), so it cannot accept writes on the way up;
ProxySQL leaves it in the reader hostgroup until something appoints it. Promotion is always
SET GLOBAL, which does not survive a restart — so the appointment has to be made deliberately,
every time.
Automatic promotion does not currently work. Orchestrator issues
SHOW SLAVE STATUS, which MySQL 8.4 removed in favour ofSHOW REPLICA STATUS, so topology discovery fails withError 1064and step ② never fires on its own. Replication, semi-sync and ProxySQL's re-election all work — a promotion done by hand propagates correctly:docker exec kafkaflowshard-mysql-slave mysql -uroot -proot \ -e "SET GLOBAL super_read_only = OFF; SET GLOBAL read_only = OFF;"Restoring automatic failover means pinning the HA nodes to
mysql:8.0; openark/orchestrator has no 8.4-compatible release.
Writability is a runtime appointment (SET GLOBAL, never SET PERSIST): any node that
restarts comes back read-only, which is the split-brain failsafe. Details and failover drills are
in ha/README-HA.md.
Three outcomes — commit, retry, or quarantine
srv_sub creates the 5sdelay (retry) and deadletter topics in code at startup
(DeadLetterProducer.EnsureTopicsAsync, same as the main topic). Each consumed message resolves
to one of three outcomes:
| MasterNode result | Action |
|---|---|
replies Ok |
commit ✓ |
| replies but rejects (e.g. shard write failed, malformed payload) | count an attempt → re-queue to SnapshotTopic (attempt header +1), or deadletter once the limit is hit; then commit |
| unreachable (TCP can’t connect) | rewind offset + wait 2s, retry — not counted as an attempt |
- Attempt count travels in a Kafka header (
attempts); the dead-lettered copy also carriesx-failure-reason. - Limit is
MaxAttempts(default 3) — a poison message is tried 3× then dead-lettered. - Transient outages don’t burn attempts, so a MasterNode restart won’t dump good packets. This distinction matters for the delivery guarantee: an unreachable MasterNode is a transport failure (rewind and wait — the packet is still good), while an explicit rejection is a processing failure (count it, and quarantine the packet after 3 strikes).
Watch the dead-letter topic fill — commands
docker exec packetshard-kafka kafka-topics --bootstrap-server localhost:9092 --list
# force rejections to see it fill: stop a shard so its writes fail
docker compose stop mongo-arp
docker exec -it packetshard-kafka kafka-console-consumer \
--bootstrap-server localhost:9092 --topic deadletter --from-beginning
Option A — everything in Docker (recommended)
cd PacketShard
docker compose up --build
This starts Consul, Zookeeper + Kafka, MySQL, the 5 MongoDB shard nodes, then MasterNode, srv_sub,
srv_pub, the ingress tier (LoadBalancer + srv_ingest) and the read side (Debezium, Postgres,
Redis, srv_read). Watch the logs: srv_pub publishes, srv_sub forwards, MasterNode prints
[shard:Https] saved ... etc.
The catalog UI at http://localhost:8500 shows every service that came up and whether its health check is passing — a quick read on what is actually alive (see Service discovery).
Which MySQL topology comes up depends on .env — a single mysql node by default, or the
primary/replica pair behind ProxySQL if the HA overlay is enabled.
docker compose ps will tell you which you got.
Inspect what landed in a shard:
docker exec -it packetshard-mongo-https mongosh --eval 'db.getSiblingDB("pcap").packets.find().limit(5)'
docker exec -it packetshard-mongo-arp mongosh --eval 'db.getSiblingDB("pcap").packets.countDocuments()'
Option B — infra in Docker, apps on the host
cd PacketShard
# Start only Kafka + MySQL + the 5 Mongo shards.
# In HA mode the MySQL service is `mysql-master` (plus `mysql-slave proxysql orchestrator
# ha-bootstrap`) rather than `mysql` — `docker compose config --services` lists what your
# current .env resolves to.
docker compose up -d zookeeper kafka mysql mongo-https mongo-tcp mongo-udp mongo-arp mongo-other
docker compose logs -f srv_pub srv_sub masternode
# In separate terminals (defaults already point at localhost):
dotnet run --project MasterNode
dotnet run --project srv_sub
dotnet run --project srv_pub
Two lanes — 174 tests split by cost, not by layer
PacketShard.Tests covers the pipeline in two lanes. The split is by cost, not by layer — the
unit lane runs anywhere in a few seconds, the infrastructure lane starts real databases via
Testcontainers:
dotnet test # 174 tests, ~33s
dotnet test --filter "Category=Unit" # 133 tests, ~5s, no Docker required
dotnet test --filter "Category=Infrastructure" # 41 tests, ~33s, needs Docker
Those are wall-clock times, which is what you actually wait for. xunit's own Duration: line
reports only the time spent inside test bodies — it says 2 s and 26 s for the two lanes,
excluding build, test-host startup and, for the infrastructure lane, container startup. Most of
that lane's wall clock is databases booting rather than assertions running: it starts a fresh
Postgres (and, for the projection tests, a Redis) per test rather than sharing one. The MySQL
suite shares a container per class and empties the table between tests instead, because MySQL
boots an order of magnitude slower.
A full run costs about the same as the infrastructure lane alone — xunit runs test classes in parallel, so the 133 in-process tests finish while the containers are still coming up.
Both lanes are tagged explicitly, so Category=Unit selects the fast one by name.
--filter "Category!=Infrastructure" picks the same 133 tests today, but it also sweeps up
anything added later without a trait — handy as a CI gate that fails loudly if a new container
test forgets its tag, and the wrong choice if you want only what is known to be in-process.
CI runs the two lanes as separate jobs (.github/workflows/ci.yml), so a broken branch is
reported by the fast one without waiting on Docker.
Why parts of it need real databases — the guarantees live in the engine, not the C#
Most of the guarantees this README claims are enforced by the database, not by the C#:
ON CONFLICT (transaction_id) DO NOTHING, WHERE EXCLUDED.version > client_state.version,
FOR UPDATE SKIP LOCKED, and the pg_ivm trigger. Against a mock every one of those tests would
pass while the system was broken, so they run against the real engines:
- Postgres is built from
postgres/Dockerfile, so the container carries pg_ivm and appliespostgres/init.sqlthrough the official entrypoint — the schema under test is the deployed one. Redelivering an event leaves one ledger row and an IMMV count of 1; eight concurrent projections of the same transaction insert exactly once. - MySQL proves the reservation: 40 rows, 4 concurrent relay workers each asking for all 40,
asserting the union is 40 distinct ids. That is
FOR UPDATE SKIP LOCKEDand nothing else. - Redis + Postgres together execute the crash-point table above — the durable write commits, the Redis marker is deliberately never set, and the redelivery must be absorbed by Postgres dedup rather than double-counted.
The MasterNode needs no containers — TestProbes in place of the five Mongo writers
The routing stage takes its shard Props as a constructor argument, so the five MongoDB writers
can be swapped for TestProbes: a packet with proto: "UDP" is asserted to reach the UDP probe
and no other. The auth gate is tested the same way — an invalid API key must produce
"Invalid API Key", close the connection, and route nothing.
Failure paths — tested as first-class behaviour, not an afterthought
A test suite that only covers success would miss the point of a durable pipeline. The suite pins the unhappy paths too: a failing Kafka publish rolls the outbox transaction back and leaves the row pending for the next tick; a failing Postgres write leaves no Redis marker behind; an abandoned reservation returns to the pool when it expires; a malformed CDC value is skipped rather than crashing the consumer.
Live pipeline — interleaved logs from srv_pub, srv_sub and masternode
Interleaved output of docker compose logs -f srv_pub srv_sub masternode: the five srv_sub
replicas (srv_sub-1..5) each forward packets and get MasterNode response: Ok, while
masternode routes them to the protocol shards — [shard:Other] saved DNS …,
[shard:Arp] saved ARP …, etc.
