<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bashiru Bukari</title>
    <description>The latest articles on DEV Community by Bashiru Bukari (@bashiru98).</description>
    <link>https://dev.to/bashiru98</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F446326%2F2d024262-af06-4413-9c7f-ff44e0613cde.png</url>
      <title>DEV Community: Bashiru Bukari</title>
      <link>https://dev.to/bashiru98</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bashiru98"/>
    <language>en</language>
    <item>
      <title>CDC without Kafka: keeping Postgres, OpenSearch, and Redis in sync with one binary</title>
      <dc:creator>Bashiru Bukari</dc:creator>
      <pubDate>Sat, 29 Aug 2026 16:27:01 +0000</pubDate>
      <link>https://dev.to/bashiru98/cdc-without-kafka-keeping-postgres-opensearch-and-redis-in-sync-with-one-binary-4ppj</link>
      <guid>https://dev.to/bashiru98/cdc-without-kafka-keeping-postgres-opensearch-and-redis-in-sync-with-one-binary-4ppj</guid>
      <description>&lt;p&gt;Every time I needed to keep Postgres in sync with something else — a search index, a cache, or another database — I seemed to end up with the same stack:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debezium → Kafka Connect → Kafka → a consumer I still had to write.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There’s nothing wrong with that stack. Kafka is great at what it does.&lt;/p&gt;

&lt;p&gt;But sometimes I just wanted this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Postgres changes → update OpenSearch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Postgres changes → update Redis.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Running a message broker for that felt like a lot of infrastructure for a fairly simple problem.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;VentStream&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It’s a single Rust binary that reads changes directly from databases, can join related records, and writes the resulting documents to your target.&lt;/p&gt;

&lt;p&gt;This post is about why I built it, some of the decisions that turned out to matter, and the numbers I got while testing it.&lt;/p&gt;

&lt;p&gt;It's open source and Apache-2.0 licensed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem that started this
&lt;/h2&gt;

&lt;p&gt;At work, we had a sync job that ran every ten minutes.&lt;/p&gt;

&lt;p&gt;It basically did this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the dataset from Postgres.&lt;/li&gt;
&lt;li&gt;Join some of the tables in application code.&lt;/li&gt;
&lt;li&gt;Rebuild the search index.&lt;/li&gt;
&lt;li&gt;Wait ten minutes.&lt;/li&gt;
&lt;li&gt;Do it all again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result was predictable.&lt;/p&gt;

&lt;p&gt;The search index could be 10–12 minutes behind the database, the database was constantly being scanned, and we were doing work even when nothing had changed.&lt;/p&gt;

&lt;p&gt;The annoying part was the joins.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;orders&lt;/code&gt; row by itself wasn't particularly useful to the search index. We wanted something more like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"customer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"customer_456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"John Doe"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So every ten minutes we were effectively asking the database for everything, joining everything, and rebuilding everything.&lt;/p&gt;

&lt;p&gt;CDC is obviously a better fit.&lt;/p&gt;

&lt;p&gt;Instead of asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What does the whole database look like?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;you ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What changed?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Postgres already knows the answer through logical replication.&lt;/p&gt;

&lt;p&gt;The problem is that most CDC setups assume you're going to put those changes into Kafka.&lt;/p&gt;

&lt;p&gt;If you already run Kafka, that's fine.&lt;/p&gt;

&lt;p&gt;If you don't, you're adding a fairly large piece of infrastructure just to move changes from one database to another system.&lt;/p&gt;

&lt;p&gt;That's the gap I wanted to explore.&lt;/p&gt;




&lt;h2&gt;
  
  
  What VentStream does
&lt;/h2&gt;

&lt;p&gt;The basic idea is pretty simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        Database
           │
           │ CDC
           ▼
      ┌───────────┐
      │ VentStream│
      └─────┬─────┘
            │
            ▼
     Search / Cache / DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VentStream connects directly to the source's native change feed, keeps the state it needs for joins, and writes to the destination.&lt;/p&gt;

&lt;p&gt;No Kafka required.&lt;/p&gt;

&lt;p&gt;Currently, the sources include:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Change mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL / Supabase&lt;/td&gt;
&lt;td&gt;Logical replication (&lt;code&gt;pgoutput&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL / MariaDB&lt;/td&gt;
&lt;td&gt;Row-based binlog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;Change streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neo4j 5.17+ Enterprise&lt;/td&gt;
&lt;td&gt;CDC log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka / Redpanda&lt;/td&gt;
&lt;td&gt;Debezium envelopes or raw topics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the sinks include:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sink&lt;/th&gt;
&lt;th&gt;How VentStream writes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenSearch / Elasticsearch&lt;/td&gt;
&lt;td&gt;Bulk API + external versioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meilisearch&lt;/td&gt;
&lt;td&gt;Task-confirmed writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;Keyspace or view materialization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SurrealDB&lt;/td&gt;
&lt;td&gt;Native RPC + real record IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important part for me is that this is all one process.&lt;/p&gt;




&lt;h2&gt;
  
  
  A real Postgres → OpenSearch config
&lt;/h2&gt;

&lt;p&gt;Here's what a Postgres → OpenSearch configuration currently looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;schema_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;cdc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;host_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_HOST&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
    &lt;span class="na"&gt;user_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_USER&lt;/span&gt;
    &lt;span class="na"&gt;password_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_PASSWORD&lt;/span&gt;
    &lt;span class="na"&gt;database_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_DATABASE&lt;/span&gt;
    &lt;span class="na"&gt;publication_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_PUBLICATION&lt;/span&gt;
    &lt;span class="na"&gt;slot_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_SLOT&lt;/span&gt;

    &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;snapshot&lt;/span&gt;
      &lt;span class="na"&gt;chunk_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;

&lt;span class="na"&gt;sink&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opensearch&lt;/span&gt;
  &lt;span class="na"&gt;opensearch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_OS_ENDPOINT&lt;/span&gt;
    &lt;span class="na"&gt;index_routing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;by_output_relation&lt;/span&gt;

&lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;health_listen&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:4043&lt;/span&gt;
  &lt;span class="na"&gt;dlq_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./ventstream-state/dlq.jsonl&lt;/span&gt;

  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;budget_bytes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;268435456&lt;/span&gt;

  &lt;span class="na"&gt;joins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;state_dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./ventstream-state/joins&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are a few things I care about here.&lt;/p&gt;

&lt;p&gt;Secrets are referenced through environment variables.&lt;/p&gt;

&lt;p&gt;Memory has an explicit budget.&lt;/p&gt;

&lt;p&gt;And the dead-letter queue is just a file.&lt;/p&gt;

&lt;p&gt;If something goes wrong, I can actually open it and inspect it.&lt;/p&gt;




&lt;h1&gt;
  
  
  The three decisions that mattered most
&lt;/h1&gt;

&lt;p&gt;Removing Kafka isn't particularly interesting by itself.&lt;/p&gt;

&lt;p&gt;The harder question is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you remove Kafka without losing the reliability properties people use it for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There were three areas where this mattered a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Don't move the cursor until the sink has the data
&lt;/h2&gt;

&lt;p&gt;This is probably the most important part of the system.&lt;/p&gt;

&lt;p&gt;With Postgres logical replication, the replication slot has a &lt;code&gt;confirmed_flush_lsn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once Postgres knows you've processed everything up to a particular LSN, it can eventually remove the older WAL.&lt;/p&gt;

&lt;p&gt;So you really don't want to say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I've read this change, so I'm done."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reading it isn't enough.&lt;/p&gt;

&lt;p&gt;VentStream only advances the replication watermark after the sink has acknowledged the write.&lt;/p&gt;

&lt;p&gt;It also tracks a contiguous sequence of successfully processed changes.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Batch 1 ✓
Batch 2 ✓
Batch 3 ✓
Batch 4 ✓
Batch 5 ✗
Batch 6 ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The watermark stays at batch 4.&lt;/p&gt;

&lt;p&gt;It doesn't jump to batch 6 because that would create a hole.&lt;/p&gt;

&lt;p&gt;That means if the process dies after batch 6, it can restart from the last confirmed point rather than assuming the missing batch was processed.&lt;/p&gt;

&lt;p&gt;I tested this by pushing 250,000 mutations through the pipeline and repeatedly hard-killing the process.&lt;/p&gt;

&lt;p&gt;The source and sink ended up with zero drift in those validation runs.&lt;/p&gt;

&lt;p&gt;That's the kind of failure case I care about much more than a pretty benchmark number.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Do the joins where the change happens
&lt;/h2&gt;

&lt;p&gt;This was the other big reason I wanted to build this.&lt;/p&gt;

&lt;p&gt;Suppose we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customers
orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An order might reference a customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orders.customer_id → customers.id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the customer changes, the order document in OpenSearch may also need to change.&lt;/p&gt;

&lt;p&gt;With a basic CDC setup, you get something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customer changed
       │
       ▼
   CDC event
       │
       ▼
   your consumer
       │
       ├── find affected orders
       ├── fetch customer
       ├── rebuild documents
       └── update OpenSearch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wanted the CDC engine itself to understand that relationship.&lt;/p&gt;

&lt;p&gt;VentStream keeps join state locally and produces the composed document.&lt;/p&gt;

&lt;p&gt;So when a customer changes, the affected orders can be recomposed and emitted again.&lt;/p&gt;

&lt;p&gt;This also exposed an interesting Postgres detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  TOAST columns
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;pgoutput&lt;/code&gt;, unchanged TOAST columns may not be included in an &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So if you treat every update as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Replace the whole document with whatever was in this event"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;you can accidentally wipe out large text fields that weren't part of the update.&lt;/p&gt;

&lt;p&gt;VentStream merges the incoming change into the stored row instead.&lt;/p&gt;

&lt;p&gt;So an update like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = "paid"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;doesn't accidentally turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;description = "a very large text field..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;into &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These are the kinds of details that don't show up in the basic CDC diagrams but become very important once you're actually running the thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. A bad row shouldn't kill the entire pipeline
&lt;/h2&gt;

&lt;p&gt;This one came from an actual failure mode.&lt;/p&gt;

&lt;p&gt;Imagine the pipeline receives a record it can't process.&lt;/p&gt;

&lt;p&gt;Maybe the payload isn't a JSON object.&lt;/p&gt;

&lt;p&gt;Maybe it doesn't look like a valid CDC event.&lt;/p&gt;

&lt;p&gt;Previously, the simplest thing to do was to stop.&lt;/p&gt;

&lt;p&gt;But that creates a nasty loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bad event
   ↓
pipeline stops
   ↓
restart
   ↓
same bad event
   ↓
pipeline stops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cursor can't move past it because the event wasn't processed.&lt;/p&gt;

&lt;p&gt;So one bad record can effectively brick the pipeline.&lt;/p&gt;

&lt;p&gt;The solution is a dead-letter queue.&lt;/p&gt;

&lt;p&gt;But there's an important detail.&lt;/p&gt;

&lt;p&gt;VentStream writes the event to the DLQ and calls &lt;code&gt;fsync()&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; advancing the cursor past its LSN.&lt;/p&gt;

&lt;p&gt;So the ordering is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bad event
   ↓
write DLQ
   ↓
fsync
   ↓
advance cursor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bad event
   ↓
advance cursor
   ↓
write DLQ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version has a nasty failure window.&lt;/p&gt;

&lt;p&gt;If the machine loses power between those two operations, the source may already have moved past the event while the DLQ entry only existed in the page cache.&lt;/p&gt;

&lt;p&gt;Now the event is gone.&lt;/p&gt;

&lt;p&gt;There are also failures that I deliberately don't put in the DLQ.&lt;/p&gt;

&lt;p&gt;For example, if the source database disappears or the sink can't be reached, that's not a bad row.&lt;/p&gt;

&lt;p&gt;That's an environment problem.&lt;/p&gt;

&lt;p&gt;The event should be retried.&lt;/p&gt;

&lt;p&gt;Otherwise you end up quarantining perfectly valid data just because your network went down.&lt;/p&gt;




&lt;h1&gt;
  
  
  So, how fast is it?
&lt;/h1&gt;

&lt;p&gt;These aren't polished benchmark-suite numbers.&lt;/p&gt;

&lt;p&gt;They're local validation runs on my machine.&lt;/p&gt;

&lt;p&gt;I'm sharing them because they helped me decide whether the architecture was actually working.&lt;/p&gt;

&lt;h3&gt;
  
  
  SurrealDB
&lt;/h3&gt;

&lt;p&gt;200,000 rows with graph edges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full bootstrap: &lt;strong&gt;11 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Peak RSS: &lt;strong&gt;23 MiB&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;RSS while tailing: &lt;strong&gt;8.3 MiB&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Re-running the bootstrap produced the same &lt;strong&gt;200,010 edges&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;No duplicate edges&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Postgres → SQL-denormalized documents
&lt;/h3&gt;

&lt;p&gt;2 million rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RSS stayed between &lt;strong&gt;24 and 86 MiB&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Memory remained bounded throughout the run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's because the memory budget isn't just a configuration value sitting there for decoration.&lt;/p&gt;

&lt;p&gt;It actually controls admission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Neo4j projection
&lt;/h3&gt;

&lt;p&gt;1 million documents plus a 250,000-row cascade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;26–100 MiB RSS&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Postgres → OpenSearch
&lt;/h3&gt;

&lt;p&gt;60,000 rows through the complete join path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;0 DLQ entries&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Exact row-for-row match between the source and index&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main reason the memory usage stays relatively low is architectural.&lt;/p&gt;

&lt;p&gt;There isn't a broker sitting in the middle serializing and deserializing every event.&lt;/p&gt;

&lt;p&gt;There's no JVM.&lt;/p&gt;

&lt;p&gt;And the joins happen once when the data changes instead of once during every full rescan.&lt;/p&gt;




&lt;h1&gt;
  
  
  But should you actually replace Kafka?
&lt;/h1&gt;

&lt;p&gt;Probably not.&lt;/p&gt;

&lt;p&gt;I don't think "Kafka is unnecessary" is a useful conclusion from this.&lt;/p&gt;

&lt;p&gt;Kafka is very good at being a distributed log.&lt;/p&gt;

&lt;p&gt;You probably want Kafka when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;many independent consumers need the same change stream&lt;/li&gt;
&lt;li&gt;consumers need to replay events independently&lt;/li&gt;
&lt;li&gt;you need long-term retention&lt;/li&gt;
&lt;li&gt;the CDC stream is becoming an organisation-wide event backbone&lt;/li&gt;
&lt;li&gt;multiple teams and applications depend on the same events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a different problem.&lt;/p&gt;

&lt;p&gt;If your architecture looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Postgres
   │
   ├── Search index
   └── Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;a single-process CDC engine can make a lot of sense.&lt;/p&gt;

&lt;p&gt;If it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         ┌── Analytics
                         │
Postgres → Kafka →───────┼── Search
                         │
                         ├── Data warehouse
                         │
                         └── 20 other consumers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kafka is probably the better tool.&lt;/p&gt;

&lt;p&gt;They're not mutually exclusive either.&lt;/p&gt;

&lt;p&gt;VentStream can consume Debezium-formatted Kafka topics, so you can keep Kafka as the event backbone and use VentStream for the join/materialization part.&lt;/p&gt;




&lt;h1&gt;
  
  
  Try it
&lt;/h1&gt;

&lt;p&gt;If you want to play with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ventstream.dev/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with Docker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run ghcr.io/ventstream/ventstream:&amp;lt;version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a demo in the repository that starts Postgres, Neo4j, and OpenSearch and streams joined documents between them.&lt;/p&gt;

&lt;p&gt;There's a live SurrealDB demo too, showing orders joined with customers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site/docs: &lt;a href="https://ventstream.dev" rel="noopener noreferrer"&gt;https://ventstream.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/ventstream/ventstream" rel="noopener noreferrer"&gt;https://github.com/ventstream/ventstream&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Comparison: &lt;a href="https://ventstream.dev/compare" rel="noopener noreferrer"&gt;https://ventstream.dev/compare&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://surreal-demo.ventstream.dev" rel="noopener noreferrer"&gt;https://surreal-demo.ventstream.dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  What's still rough?
&lt;/h1&gt;

&lt;p&gt;Quite a bit.&lt;/p&gt;

&lt;p&gt;It's early. The &lt;code&gt;0.1.x&lt;/code&gt; version numbers are there for a reason.&lt;/p&gt;

&lt;p&gt;A few things I'm not happy with yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PostgreSQL → PostgreSQL isn't supported as a sink yet.&lt;/li&gt;
&lt;li&gt;The DLQ replay behaviour for poison rows needs better documentation.&lt;/li&gt;
&lt;li&gt;Bootstrap could deduplicate upstream lookups more aggressively.&lt;/li&gt;
&lt;li&gt;There are still plenty of edge cases that only show up when you run CDC for long enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's also why I'm putting this out there now.&lt;/p&gt;

&lt;p&gt;I'd rather have people try it against real systems and tell me where it breaks than pretend it's finished.&lt;/p&gt;

&lt;p&gt;If you run VentStream and it does something weird, &lt;strong&gt;please open an issue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Those reports are much more useful to me than another benchmark where everything runs perfectly.&lt;/p&gt;




&lt;h2&gt;
  
  
  One last thing
&lt;/h2&gt;

&lt;p&gt;The main reason I built this isn't because I think Kafka is bad.&lt;/p&gt;

&lt;p&gt;It's because I kept seeing relatively simple data-sync problems turn into relatively large infrastructure projects.&lt;/p&gt;

&lt;p&gt;Sometimes you need Kafka.&lt;/p&gt;

&lt;p&gt;Sometimes you just need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;database → change feed → transform → destination
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for that second case, I wanted to see how far a single binary could go.&lt;/p&gt;

&lt;p&gt;That's what VentStream is trying to be.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>rust</category>
      <category>database</category>
      <category>opensource</category>
    </item>
    <item>
      <title>50M docs bootstrapped per source into OpenSearch on 2 vCPU / 1GB on ventstream</title>
      <dc:creator>Bashiru Bukari</dc:creator>
      <pubDate>Thu, 20 Aug 2026 23:30:55 +0000</pubDate>
      <link>https://dev.to/bashiru98/50m-docs-bootstrapped-per-source-into-opensearch-on-2-vcpu-1gb-on-ventstream-30jc</link>
      <guid>https://dev.to/bashiru98/50m-docs-bootstrapped-per-source-into-opensearch-on-2-vcpu-1gb-on-ventstream-30jc</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficgpz62g83qsterx8eyr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficgpz62g83qsterx8eyr.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I build an open-source CDC engine (single Rust binary). A user asked if it handles big workloads, so I ran 50M pre-seeded docs from each source through a snapshot bootstrap into OpenSearch, engine capped at 2 vCPU / 1GiB, and verified every run against the exact source count. This is impressive for just a single rust binary&lt;/p&gt;

&lt;p&gt;I can now comfortably move away from kafka and debezium.&lt;/p&gt;

&lt;p&gt;try it here &lt;a href="https://ventstream.dev/docs" rel="noopener noreferrer"&gt;https://ventstream.dev/docs&lt;/a&gt;&lt;br&gt;
I need your feedback and contributions&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>ai</category>
      <category>dataengineering</category>
      <category>cdc</category>
    </item>
    <item>
      <title>How to sync PostgreSQL to Meilisearch in real time — without dual-writes</title>
      <dc:creator>Bashiru Bukari</dc:creator>
      <pubDate>Sun, 16 Aug 2026 10:00:03 +0000</pubDate>
      <link>https://dev.to/bashiru98/how-to-sync-postgresql-to-meilisearch-in-real-time-without-dual-writes-23b8</link>
      <guid>https://dev.to/bashiru98/how-to-sync-postgresql-to-meilisearch-in-real-time-without-dual-writes-23b8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://ventstream.dev/blog/sync-postgresql-to-meilisearch-without-dual-writes" rel="noopener noreferrer"&gt;VentStream blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you run PostgreSQL and want Meilisearch to stay in step with it, the usual first attempt is writing to both from application code. Two writes, no shared transaction — and a set of headaches that never really goes away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial-failure drift&lt;/strong&gt; — a crash between the two writes leaves the index lying about the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry duplicates&lt;/strong&gt; — replaying the code path writes the same document twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgotten deletes&lt;/strong&gt; — a row disappears from Postgres and lives on in search forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backfill scripts&lt;/strong&gt; — a one-off "initial load" path that behaves differently from the live one and drifts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync logic everywhere&lt;/strong&gt; — every new code path that touches the table has to remember to touch the index.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The database already has the truth &lt;em&gt;and&lt;/em&gt; a change feed — use it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The guide: Postgres to Meilisearch in four steps
&lt;/h2&gt;

&lt;p&gt;The flow is: &lt;strong&gt;PostgreSQL (WAL) → publication → VentStream → Meilisearch&lt;/strong&gt;. VentStream is an open-source (Apache-2.0) Rust engine that attaches to Postgres logical replication and materializes documents into the index.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Publish the table
&lt;/h3&gt;

&lt;p&gt;The table owner decides what leaves the database, in the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;vs_pub&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Write the config
&lt;/h3&gt;

&lt;p&gt;This is the entire file. Secrets are environment references, never inline values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;schema_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;cdc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;host_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_HOST&lt;/span&gt;
    &lt;span class="na"&gt;user_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_USER&lt;/span&gt;
    &lt;span class="na"&gt;password_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_PASSWORD&lt;/span&gt;
    &lt;span class="na"&gt;database_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_DATABASE&lt;/span&gt;
    &lt;span class="na"&gt;publication_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_PUBLICATION&lt;/span&gt;
    &lt;span class="na"&gt;slot_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_PG_SLOT&lt;/span&gt;
    &lt;span class="na"&gt;bootstrap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;snapshot&lt;/span&gt;

&lt;span class="na"&gt;sink&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meilisearch&lt;/span&gt;
  &lt;span class="na"&gt;meilisearch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;endpoint_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_MEILI_ENDPOINT&lt;/span&gt;
    &lt;span class="na"&gt;api_key_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env:VS_MEILI_KEY&lt;/span&gt;
    &lt;span class="na"&gt;index_routing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fixed&lt;/span&gt;
      &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole setup. The engine also supports table joins — composing an order with its line items into one document — but we're keeping this one simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Set the secrets
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;env:&lt;/code&gt; reference in the config resolves from the engine's environment at startup, so the file itself stays safe to commit. Put the values wherever you normally keep secrets — an env file, systemd &lt;code&gt;Environment=&lt;/code&gt; directives, or a Kubernetes Secret. Locally, an env file next to your &lt;code&gt;ventstream.yaml&lt;/code&gt; does fine — the shell exports it before the engine starts; the engine itself only reads environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env — the values the config references&lt;/span&gt;
&lt;span class="nv"&gt;VS_PG_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;db.internal
&lt;span class="nv"&gt;VS_PG_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ventstream
&lt;span class="nv"&gt;VS_PG_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;…
&lt;span class="nv"&gt;VS_PG_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;shop
&lt;span class="nv"&gt;VS_PG_PUBLICATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vs_pub
&lt;span class="nv"&gt;VS_PG_SLOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vs_slot
&lt;span class="nv"&gt;VS_MEILI_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://search.internal:7700
&lt;span class="nv"&gt;VS_MEILI_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Install and run
&lt;/h3&gt;

&lt;p&gt;The installer covers macOS and Linux. On Windows, use WSL2 or the container image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ventstream.dev/install.sh | sh

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; ./.env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;span class="nv"&gt;VS_ENGINE_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./ventstream.yaml ventstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What you see
&lt;/h3&gt;

&lt;p&gt;The engine snapshot-bootstraps your existing rows in keyset-paginated chunks, then tails the WAL from the exact watermark where the snapshot ended — same document shapes, same ids, one continuous motion. There is no "initial load script" that behaves differently from the live path. Once it is tailing, live changes become searchable within milliseconds to seconds of the commit, depending on the sink's ingestion speed. One detail to know: the sink namespaces index names, so &lt;code&gt;index: orders&lt;/code&gt; shows up in Meilisearch as &lt;code&gt;vs_orders&lt;/code&gt; (the prefix is configurable).&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it stays correct
&lt;/h2&gt;

&lt;p&gt;The short version of how VentStream keeps the index and the table in lockstep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every document gets a deterministic id derived from the primary key — canonical form &lt;code&gt;public.orders:["42"]&lt;/code&gt;, carried as &lt;code&gt;_vs_id&lt;/code&gt;; the Meilisearch primary key &lt;code&gt;_vs_pk&lt;/code&gt; is its URL-safe encoding. Re-emits overwrite, never duplicate.&lt;/li&gt;
&lt;li&gt;Updates upsert in place. Deletes always find their target. Even a primary-key &lt;code&gt;UPDATE&lt;/code&gt; removes the old document and writes the new one.&lt;/li&gt;
&lt;li&gt;Writes are Meilisearch task-confirmed in FIFO order, and the engine's cursor only advances after the sink confirms durability — so after a crash or restart, the engine resumes from the last confirmed write rather than skipping or replaying events.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How fast is it?
&lt;/h2&gt;

&lt;p&gt;Depending on the configuration and the resources you give it, the engine sustains from thousands up to tens of thousands of events per second. Our verified benchmarks reached roughly 930k documents per minute bootstrapping 4 million rows into Meilisearch on a laptop (M3 Max, single node, exact-count-verified), with the engine around 10–14% of one core. Meilisearch's own ingestion is usually the floor, not the engine.&lt;/p&gt;

&lt;p&gt;When flat rows stop being enough — say you want each order indexed &lt;em&gt;with&lt;/em&gt; its line items — VentStream can compose parent and child rows into one document with a &lt;a href="https://ventstream.dev/docs/concepts/cdc-and-projections" rel="noopener noreferrer"&gt;joins spec&lt;/a&gt;. That deserves its own walkthrough; we'll cover it in a follow-up post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it wherever you like
&lt;/h2&gt;

&lt;p&gt;The engine is open source and runs anywhere a binary runs. When you want managed configuration, health, and operations across a fleet, the same binary attaches to &lt;a href="https://ventstream.dev" rel="noopener noreferrer"&gt;VentStream Cloud&lt;/a&gt; with a single agent key — no key, and it never talks to the platform at all.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/ventstream/ventstream" rel="noopener noreferrer"&gt;github.com/ventstream/ventstream&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://ventstream.dev/docs" rel="noopener noreferrer"&gt;ventstream.dev/docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Original post: &lt;a href="https://ventstream.dev/blog/sync-postgresql-to-meilisearch-without-dual-writes" rel="noopener noreferrer"&gt;ventstream.dev/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions or a source/sink you'd like to see supported? Open an issue — I read all of them.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>meilisearch</category>
      <category>cdc</category>
      <category>opensource</category>
    </item>
    <item>
      <title>VentStream — open source CDC engine that syncs your database to search, cache, and AI agents in realtime</title>
      <dc:creator>Bashiru Bukari</dc:creator>
      <pubDate>Tue, 11 Aug 2026 18:21:56 +0000</pubDate>
      <link>https://dev.to/bashiru98/ventstream-open-source-cdc-engine-that-syncs-your-database-to-search-cache-and-ai-agents-in-23jl</link>
      <guid>https://dev.to/bashiru98/ventstream-open-source-cdc-engine-that-syncs-your-database-to-search-cache-and-ai-agents-in-23jl</guid>
      <description>&lt;p&gt;Keeping downstream systems in sync with a database is one of those problems every team ends up solving badly — cron jobs that miss deletes, sync scripts that drift, search indexes you can only trust after the nightly rebuild. The only time you find out something went wrong is when a user reports stale data.&lt;/p&gt;

&lt;p&gt;VentStream solves this by reading the database's change stream directly and keeping replicas in sync within milliseconds — including the hard parts: joined documents, delete propagation, and exactly-once delivery through deterministic document ids.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; Postgres, MySQL, MongoDB, Neo4j, Kafka&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sinks:&lt;/strong&gt; OpenSearch / Elasticsearch, Meilisearch, Redis&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realtime:&lt;/strong&gt; GraphQL subscriptions and WebSocket fan-out with cursor resume — reconnect and pick up exactly where you left off&lt;/p&gt;

&lt;p&gt;You can declare joins once — an order embeds its customer and line items — and the engine keeps the composed documents in sync from the change stream. A delete on a child row updates the parent document in the index. No sync jobs, no invalidation code.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing we just shipped: an MCP server for AI agents
&lt;/h2&gt;

&lt;p&gt;The engine now includes a built-in MCP server, so AI agents (Claude Desktop, Claude Code, any MCP client) can query your synced data directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agents get live answers without holding any database credentials&lt;/li&gt;
&lt;li&gt;Scoped access tokens per agent — an agent sees only the targets you allow&lt;/li&gt;
&lt;li&gt;The joins spec doubles as schema documentation the agent discovers by itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Point Claude at your replicas with one subcommand and it can answer "what's the status of order 4351?" from data that's milliseconds fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;One line, macOS and Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://ventstream.dev/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stream a MongoDB database into Elasticsearch — this is the whole setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VS_ROLES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cdc &lt;span class="nv"&gt;VS_CDC_SOURCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mongodb &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_MONGO_URI&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'mongodb+srv://user:pass@cluster.example.net/'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_MONGO_DATABASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;shop &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_MONGO_STATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./state &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_MONGO_BOOTSTRAP_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;snapshot &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_OS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:9200 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;VS_INDEX_TEMPLATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'${header:ventstream.cdc.relation}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
ventstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://ventstream.dev/docs" rel="noopener noreferrer"&gt;https://ventstream.dev/docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/ventstream/ventstream" rel="noopener noreferrer"&gt;https://github.com/ventstream/ventstream&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking for testers and contributors
&lt;/h2&gt;

&lt;p&gt;The engine is running in production against sustained load, but real-world databases are where sync tools earn their keep — different schemas, different scales, different failure modes. If you run Postgres, MySQL, MongoDB, or Neo4j and have ever fought a sync pipeline, I'd genuinely like to hear what breaks or what's missing.&lt;/p&gt;

&lt;p&gt;Open to contributors as well — the connector surface (Typesense and ClickHouse sinks are on the list), the MCP tools, and docs are all good places to start. Issues and discussions are open on the repo.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>database</category>
      <category>rust</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
