Every relevant change in Woovi’s MongoDB has to reach several places. When a charge is paid, the dashboard search has to reflect it, the payer’s screen has to show “paid”, the merchant’s Pix card machine and thermal printer have to know, and other services (in-platform notifications, analytics, invoicing, e-commerce integrations) consume the event through Kafka.
For a long time, a single Node.js process did all of this. This post covers how we took that work away from it in two stages: first the Elasticsearch indexing, then the domain events in Kafka. The Flink concepts that show up here are explained in Apache Flink: what it solves and what it costs to run. The numbers are rounded and internal names were replaced with descriptions.
The starting point
The process, which I’ll call the publisher, opened one change stream per MongoDB collection with fullDocument: 'updateLookup' and ran the side effects inside itself. There were about 20 streams and a few million events per day, all in a single pod.
The side effects broke down like this:
| Effect | Example | Who notices |
|---|---|---|
| Elasticsearch indexing | Charges, transactions, companies | Dashboard search |
| Domain events in Kafka | charge.upserted, charge.completed | In-platform notifications, analytics, invoicing, e-commerce |
| Real time | GraphQL subscriptions via Redis PubSub, MQTT for the card machine and printer | The payer |
| Cache invalidation | Company, account, application | Security and consistency |
| External integration | Customer support tool | Support |
| None | Streams left over from old features | Nobody, but they cost reads |
Whenever this post talks about notifications, it means notifications inside Woovi’s own platform.
flowchart LR M[(MongoDB<br/>oplog)] -->|~20 change streams| P["publisher<br/>1 Node.js pod"] P -->|index| ES[(Elasticsearch)] P -->|PubSub| R[Redis<br/>subscriptions] P -->|publish| Q[MQTT<br/>card machine and printer] P -->|jobs| B[queue<br/>clears cache] P -->|HTTP| C[Customer support] P -->|send| K[(Kafka)]
Simplified, the core of the publisher was this:
// simplified version of the pattern that existedconst stream = Model.watch([], { fullDocument: 'updateLookup' })
stream.on('change', async (change) => { // the position is saved in Redis, with a 60 s TTL, before the effect await saveResumeToken(change._id) await handler(change)})
async function sendToTopic(topic, payload) { try { await producer.send({ topic, messages: [{ value: JSON.stringify({ ...payload, eventId: uuidv4() }) }], }) } catch (err) { logger.error(err) // the error dies here }}Every line of that snippet hides a problem.
Why migrate
The reason was never cost. The publisher delivered at-most-once by construction and lost events without a trace, and in several cases the end customer was affected.
The defects were in the design from the start, but while volume was low they barely showed. The problems began as the company grew, and especially after a spike in event volume: with more load, concurrent handlers piled up, memory blew up, and every restart opened room for lost events.
These were the mechanisms:
- The position was saved before the effect. If the process died after writing the resume token and before the handler finished, the event was gone.
- The position had a 60 second TTL. A restart slower than that couldn’t find the token and started reading again “from now”. This was the most dangerous loss, because it didn’t produce a single log line.
- Send errors were swallowed. No retry and no dead letter. There were two more silent paths: messages dropped when the producer wasn’t initialized, and a swallowed initial connection failure that left the process without Kafka until the next restart.
- The
eventIdwas random. No consumer could tell a redelivery from a new event. - There was no backpressure.
EventEmitterdoesn’t wait for an async listener, so every event became a concurrent handler. The p50 of the charge handler went from tens of milliseconds off peak to over 10 seconds at peak. - It didn’t scale. A change stream has no consumer group: a second replica would process everything twice. It was a single pod, with every effect on the same event loop.
There were smaller problems too. Concurrent handlers didn’t guarantee per-document ordering. A stream closed by a non-resumable error stayed closed while the process kept running and the health check stayed green. And each oplog reader (the publisher and the Flink jobs that already existed) did its own updateLookup.
How the losses showed up
A few episodes give a sense of the problem:
- A single 502 from Elasticsearch in a background indexing call became an
unhandledRejectionand took the process down, along with every event in flight. - After an OOM, the Kafka producer came back without topic metadata and stayed that way for over 40 minutes. About 3 thousand sends were lost, including paid charge events.
- When the charge event payload got heavier, the lack of backpressure led to several OOMs on the same day. Replaying the backlog brought the process down again a few minutes later.
- At peak hours, the producer failed in bursts. In one week, tens of thousands of sends were lost.
The most telling case had no error at all. On two consecutive days, the change stream ran for about 10 minutes at 3% of normal throughput, with no exception, no restart and nothing in the APM. When it came back, throughput returned to its normal level with no burst of backlog: the events weren’t delayed, they had been lost.
During those windows, some paid charges from stores integrated with an e-commerce platform never became orders, because the integration only marks the order as paid from the charge.completed event. We found out from the merchant, whose customer paid and couldn’t find the order. The fix was manual, with a script that settled the charges paid during the window. There was no way to resend the Kafka event, and no way for the publisher itself to detect the loss.
Stage 1: Elasticsearch
Indexing was the first thing to move out. A dozen collections started being indexed by a dedicated Flink job, and the publisher got a flag to turn that part off.
Search was a good first candidate. It tolerates a few seconds of delay, reprocessing just rewrites the same document in the index (idempotent by nature), and it was exactly the effect that contaminated the others the most: slowness or errors in Elasticsearch delayed or crashed the process that also handled the “paid” screen.
With the job split out, Elasticsearch backpressure only delays search. The job reads the oplog, transforms the document and writes to the index, and if the cluster slows down the source simply reads more slowly.
This stage also served as operational training. It’s where we learned that a stopped Flink job doesn’t warn anyone: the indexing job once sat almost two days in FAILED after a database maintenance. That’s where the job state and source lag alerts that now cover every job came from.
Before stage 2, we cleaned up the publisher: we removed the leftover Elasticsearch code and the streams with no effect at all (from about 20 down to 10), which eliminated more than a million reads per day.
Stage 2: domain events in Kafka
The central decision was to separate capture from effect. Capture is generic and needs state: read the oplog, keep the position, build the event, publish. The effect is domain code: render the receipt for the printer, translate messages, build the subscription payload.
Flink took capture. It publishes one <entity>.upserted topic per collection. The effects stayed in TypeScript, as Kafka consumers.
flowchart LR M[(MongoDB<br/>oplog)] -->|"1 CDC source per collection"| FE["Flink job<br/>events"] FE -->|"entity.upserted<br/>key = _id"| K[(Kafka)] K --> W["Node consumers<br/>general topics"] K --> WC["Node consumer<br/>charges, 6 partitions"] K --> O["other services<br/>in-platform notification, analytics..."] W --> R[Redis PubSub] & Q[MQTT] & B[cache queue] & C[Customer support] WC --> R & Q M -->|CDC| FES["Flink job<br/>indexing"] --> ES[(Elasticsearch)]
Porting the effects into Flink would have meant rewriting in Java the printer’s ESC/POS rendering, the internationalization and the real-time BR Code generation, plus giving the job MQTT credentials and access to the product’s Redis. And any job restart would stop the “paid” screen.
We also considered the usual alternatives:
| Option | Why not |
|---|---|
| Fix the publisher (bounded concurrency, token after the effect, no TTL) | Still no horizontal scaling and still a single point of failure |
| Debezium / Kafka Connect | One more runtime next to Flink, which we already operated |
| Outbox in the write path | The main collections have dozens of write points, across code, scripts and migrations. Saved for new business events |
The job
Each collection has its own pipeline: CDC source, deserialization, filter (drops deletes), event assembly (with an ordered async lookup when the event needs data from another collection) and KafkaSink. The source has parallelism 1, because a change stream can’t be split. When a pipeline runs with higher parallelism, a keyBy(_id) comes in and each document stays in order.
To isolate collections from each other:
- the job has its own deployment, separate from the indexing one, so Elasticsearch never delays the “paid” screen;
- high-volume collections sit in dedicated slot sharing groups;
- failover is per region, and a misbehaving source restarts only its own collection;
- restart is
exponential-delayfrom 1 s to 60 s, with no global failure budget.
No operator keeps per-key state (the only state is the source positions and the Kafka writers), so the backend is hashmap. Checkpoints run every 60 seconds and are retained when the job is cancelled.
The sink configuration, summarized:
KafkaSink<DomainEvent> sink = KafkaSink.<DomainEvent>builder() .setBootstrapServers(bootstrapServers) .setRecordSerializer(new DomainEventSerializer()) // key = _id in UTF-8 .setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE) .setKafkaProducerConfig(producerProps) // acks=all, idempotence, compression=none .build();We picked AT_LEAST_ONCE over EXACTLY_ONCE for latency. With transactions, a read_committed consumer would only see the record at the next checkpoint, up to 60 seconds later. The producer runs with acks=all, idempotence on and up to 5 in-flight requests, which preserves per-partition ordering.
One detail made the transition easier: Flink’s producer is compatible with the kafkajs producer the publisher used. The key is the same (_id in UTF-8) and so is the partitioner (murmur2), so a document lands on the same partition with either one. Compression stayed off because the kafkajs consumers only had the GZIP codec.
Deterministic id
The eventId stopped being random. It’s a UUID v5 computed from the event type and the change’s resume token, so the same change always has the same id, no matter how many times it’s resent. That’s what makes at-least-once bearable on the consumer side.
Lean projections
Each event carries the entity’s state, so consumers don’t need to read the document again. We used the opportunity to cut down on personal data: the customer goes with only id, name, email and phone, and fields no consumer used were dropped from the payload.
Domain logic in two languages
The charge creation event carries the payment methods, including the Pix BR Code. To generate that in Flink, we ported to Java the BR Code generation, the accent removal table and JavaScript’s serialization semantics for dates, numbers and amounts. The BR Code package alone is about 1,400 lines.
Parity is guaranteed by fixtures generated from the TypeScript code, with more than a hundred cases compared byte for byte. If someone changes the generation in TypeScript, the Java tests break until the fixtures are regenerated and the port catches up.
The consumers
Each topic has a handler built by the same factory, which follows four steps:
- Ignore events from another producer (some topics still receive events published directly by the write code).
- Claim the
eventIdin Redis withSET NXand a 1 hour expiry. A job restart resends the last few minutes with the same ids, and a receipt can’t be printed twice. - Rebuild the “change” from the event, hydrating the document into the Mongoose model with only the fields present, without schema defaults that would invent values.
- Call exactly the same functions the publisher called. A delay of 5 seconds or more since the database write produces a slowness log.
// simplified version of the claimconst claimed = await redis.set(`event:${event.eventId}`, '1', 'EX', 3600, 'NX')if (!claimed) return // redelivery, already processed
await handleChange(rebuildChange(event))The consumer group offset only advances after processing. If the consumer dies, the event stays in the topic. The charges topic, which carries most of the volume, has 6 partitions and its own deployment that scales up to one replica per partition.
The cutover
The first design used time windows per effect: each side knew, through configuration, from what point an effect belonged to it. It would have worked, but it required parsers on both sides and several coordinated deployments. We swapped it for two flags:
- one in Flink, which starts emitting the topics the publisher used to emit as well;
- another shared by the publisher (which stops opening the streams) and the consumers (which start processing).
That implies a window where both sides perform the same effects. Before turning it on, we audited every external consumer of the topics: the platform notifications one deduplicates through a unique index, the analytics one uses fixed job ids and recomputes, another does an upsert. The known exception was the in-platform “charge paid” push notification, with no deduplication, which went out twice during the overlap.
Order matters. Turn the consumers on first and then turn the publisher off, accepting duplicates. In the reverse order, the gap between the two deploys would lose effects. In production, there were about 20 minutes of duplicated effects. Rollback is the reverse, with one caveat: before turning the consumers back on, the consumer groups need to be repositioned at the end of the topic, or they reprocess everything that was left behind.
Verification had three parts. In staging, we compared publisher and Flink payloads for real charges, on insert and update, and they were identical except for a few new fields. In production, we compared, in 10 minute windows, the publisher’s APM count, the changes read by Flink and the offset progress of the consumer groups, and all three matched within about 1%. Finally, we watched the commands in Redis: the claim SET NX calls showed up at the moment of the switch, and the PubSub PUBLISH calls went up during the overlap and returned to their previous level afterwards.
From the start of the analysis to the removal of the publisher’s code and infrastructure took four days. The removal took about 10 thousand lines out of the monorepo.
Before and after
| Publisher (Node) | Flink + consumers | |
|---|---|---|
| Delivery guarantee | At-most-once | At-least-once, with consumer-side deduplication |
| Read position | Redis with a 60 s TTL, saved before the effect | Checkpoint state, committed after the Kafka flush |
| Horizontal scaling | No (1 pod) | Yes, per partition |
| Per-document ordering | Not guaranteed | Guaranteed (_id as key) |
| Capture CPU | Baseline | About 10× less |
| Capture memory | Peaks near 3 GiB, with OOMs | Just over 2 GiB, flat (JVM) |
| Observed loss | Tens of thousands of sends per week, plus the stall windows | None since the cutover |
In staging, a consumer was down for 25 minutes because of a deploy mistake. When it came back, it consumed the backlog with no loss and not a single handler failure. Under the old model that would have been impossible.
What got worse
Latency
End-to-end latency got worse in some windows, mainly at peak hours and during the nightly database backup. The causes are operational: oplog reading falls behind when the node the CDC reads from is under load, and the charges consumer still runs with too little CPU for the volume it receives.
The comparison with the publisher needs care. The old metric was handler duration, not delay since the write. And in the new model delay isn’t loss: the backlog waits in Kafka. In the old one, the same excess load turned into OOMs and lost events.
Migration pitfalls
- A consumer that used a different Kafka library ignored the new partitions when the charges topic went from 1 to 6 partitions. A few thousand events sat there until an offset reset.
- In staging, developer machines were running the old publisher against the staging database and duplicating every effect. We found the culprits by IP in Redis
MONITORand by MQTT latency: 10 to 30 ms came from a change stream, around 350 ms came from the Flink, Kafka and consumer path. - A consumer pod came up in production without the flag. Checking the startup lines became part of the procedure.
- The removal broke a staging release. The infrastructure PR was merged seconds before the code PR, and the release picked up the old pipeline definition, which still tried to build the publisher. Removing something is also a deploy, and the CI change has to go first.
Lessons
- The read position is part of the data. Saving the resume token before the effect, or with a TTL, turns any failure into silent loss.
EventEmitteris not a queue. An async listener withoutawaitis unbounded concurrency, and load shows up as latency, then OOM, then loss.- Separating capture from effect let each part run on the runtime that makes sense for it, and Kafka in the middle decouples the two.
- Port to the other language only the minimum that has to be in the event, and protect it with fixtures generated from the original code.
- A deterministic
eventIdplus consumer-side deduplication is what makes at-least-once tolerable. - A deliberate overlap is simpler than time windows, as long as each consumer’s idempotency was audited first. The only consumer that didn’t deduplicate was exactly the one the customer saw.
- Measure per-collection throughput against what you expect. The stall that lost orders didn’t produce a single error.
- Delay isn’t loss, but it needs an owner. The new path ended the losses and brought new delays, which are now tuning work on oplog reading and on the consumer.