Skip to content

Buffer QuestDB writes through an outage and replay them - #18

Merged
ichnograph merged 1 commit into
masterfrom
feat/questdb-write-buffer
Aug 11, 2026
Merged

Buffer QuestDB writes through an outage and replay them#18
ichnograph merged 1 commit into
masterfrom
feat/questdb-write-buffer

Conversation

@ichnograph

Copy link
Copy Markdown
Contributor

Follow-up to #17. Reconnecting stopped the permanent wedge, but every reading taken while QuestDB was down was still dropped. This holds them and replays them.

Where the data was actually going

The obvious design (buffer rows once we know we are disconnected) does not catch the loss. Rows handed to the ILP client sit in its internal buffer until the next flush, and tcpLineSender.Flush discards that buffer when the write fails. The sink only discovers the outage at flush time, by which point the rows that flush was carrying are gone.

So the buffer holds every row until a flush confirms it, and clears on a successful flush. In the normal case that is one flush interval of rows.

Overflow

QuestDB.MaxBufferBytes caps it, 4 MiB by default. Sizing comes from a sizingSender that implements LineSender and measures the ILP encoding of a row; the client does not expose the encoded length. Estimates round up, so the cap is not exceeded.

Past the cap the oldest rows are evicted and the store call starts returning errors, which the service's consecutive-error escalation turns into a non-zero exit after five. The pod crash-loops for as long as QuestDB is down rather than growing until the kernel kills it. MaxBufferBytes: 0 restores the v1.5.3 drop-on-disconnect behaviour.

Health: degraded is not stuck

This needed a health-server change, otherwise the fix defeats itself: liveness flips after 90s of unhealthiness and the restart throws away the buffer that is holding the data.

healthserver.Degrader is an optional Checker extension. A checker that is failing but recovering in place reports Degraded() true and the liveness threshold skips it. Readiness is untouched and goes red on the first failure, so the outage is still visible and alertable.

State /readyz /healthz Result
Connected 200 200 normal
Disconnected, buffer has room 503 200 pod stays up, readings held
Disconnected, buffer full 503 503 restart loop, readings dropped

Known limit

ILP over TCP has no server acknowledgement. The last flush before a socket error is reported as successful even though QuestDB never stored those rows, so they cannot be replayed. The buffer covers everything from the first reported failure onwards. Closing that gap properly means moving to the HTTP transport, which gets a real server response; that is a bigger change and a separate decision.

Tests

  • End-to-end over a real TCP listener: write and flush through a server-side close, then assert the held rows arrive on the second accepted connection. This is what surfaced the flush-buffer gap above; the first version of the fix passed the unit tests and failed this one.
  • Overflow evicts oldest, keeps newest, returns an error, and stops shielding liveness.
  • Replay failure keeps the rows it never handed over.
  • Buffering disabled keeps the old drop semantics.
  • Liveness ignores a degraded checker indefinitely, and trips once it stops being degraded.
  • qdb 95.5%, healthserver 96.6%, total 83.7%. Lint clean, race clean.

Reconnecting stopped the permanent wedge but still lost every reading
taken while QuestDB was down. The sink now holds rows in memory and
replays them in order once the connection is back.

Rows are held until a flush confirms them, not just while the client
knows it is disconnected. The ILP client keeps written rows in its own
buffer until the next flush and throws that buffer away when the flush
fails, so by the time the sink learns the socket is dead the readings it
accepted are already gone. Holding them until the flush succeeds is what
makes them recoverable.

QuestDB.MaxBufferBytes caps the buffer, 4 MiB by default, sized from an
estimate of the ILP encoding of each row. Past the cap the oldest rows
are evicted and the store call starts failing, which the service
escalates to a process exit. The pod then crash-loops until QuestDB is
back, instead of growing until the kernel kills it. Zero restores the
previous drop-on-disconnect behaviour.

That needed a health-server change. Liveness would have restarted the
pod after 90s and thrown away the buffer holding the data. A checker can
now implement Degrader to say it is failing but recovering in place, and
the liveness threshold skips it. Readiness still goes red immediately,
so the outage stays visible.

One limit stays: ILP over TCP has no server acknowledgement, so the last
flush before a socket error reports success even though QuestDB never
stored those rows. They cannot be replayed. Everything from the first
reported failure onwards is covered.
@ichnograph
ichnograph merged commit 45c1769 into master Aug 11, 2026
8 checks passed
@ichnograph
ichnograph deleted the feat/questdb-write-buffer branch August 11, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant