Skip to content

fix(uitvraag): begrens de Redis-commando's per ophaalronde - #296

Open
ericwout-overheid wants to merge 9 commits into
feature/magazijn-bulkhead-wachtrijfrom
fix/redis-store-batchgrootte
Open

fix(uitvraag): begrens de Redis-commando's per ophaalronde#296
ericwout-overheid wants to merge 9 commits into
feature/magazijn-bulkhead-wachtrijfrom
fix/redis-store-batchgrootte

Conversation

@ericwout-overheid

@ericwout-overheid ericwout-overheid commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Wat er aan de hand was

Een ondernemer die bij honderd organisaties is aangesloten, zag alle honderd organisaties netjes
langskomen — en kreeg dan geen lijst. Het ophalen eindigde met "Stream afgebroken: TypeError:
network error"
. Alle organisaties waren op dat moment wél bevraagd en hadden geantwoord; het ging
mis in de allerlaatste stap, het bewaren.

Juist de ondernemer met de meeste aansluitingen kreeg dus niets te zien, én geen uitleg: de
verbinding viel gewoon weg, wat aan zijn kant op een netwerkstoring lijkt.

Het bleken twee oorzaken die elkaar maskeerden.

Oorzaak 1 — het bewaren bood alle commando's tegelijk aan

RedisBerichtenCache.store schreef per bericht twee commando's (HSET + EXPIRE) en bood ze
allemaal tegelijk aan met Uni.join().all(...). Die subscribet op alles tegelijk, dus alle 2×N
commando's gingen in één keer de connection-wachtrij in. De Vert.x-client begrenst die op
quarkus.redis.max-waiting-handlers (default 2048).

Bij honderd organisaties × 27 berichten zijn dat ruim 4300 commando's. De configuratie staat
bovendien 500 berichten per organisatie toe, dus het theoretische plafond is 100 × 500 × 2 =
100.000. Een hogere max-waiting-handlers verplaatst die grens dus alleen — het issue vraagt
expliciet om een grens die niet met het aantal organisaties meegroeit.

Wat er nu gebeurt. Een nieuwe herbruikbare helper RedisBatching.inBatches(...) biedt de
commando's in batches aan: een batch wordt pas aangeboden als de vorige beantwoord is. Het aantal
in-flight commando's is daarmee losgekoppeld van de fan-out.

De transactie blijft daarbij volledig intact. Het issue vermoedde dat batchen de
transactie-semantiek zou raken; dat geldt alleen voor de variant met een transactie per batch.
Redis antwoordt op elk commando binnen een MULTI met +QUEUED en voert pas bij EXEC uit, dus
sequentieel aanbieden begrenst enkel het aanbieden. De hele berichtenlijst blijft één transactie.

Dezelfde helper vervangt het Uni.join()-patroon ook in renewBerichtTtls en
pruneListEnDelHash. Die twee zijn vandaag níet stuk (begrensd door pageSize, respectievelijk
0 of 1 LREM); ze gaan mee zodat het onbegrensde patroon nergens meer in het bestand staat en niet
terugkeert zodra zo'n lijst later wél meegroeit.

Oorzaak 2 — de foutmelding bereikte de gebruiker nooit

Het issue merkte op dat de OPHALEN_FOUT-route al bestond maar niet geraakt werd. De reden:

public class io.vertx.core.impl.NoStackTraceThrowable extends java.lang.Throwable

NoStackTraceThrowable — het type waarmee Vert.x "Redis waiting queue is full" meldt — erft van
Throwable, niet van Exception (geverifieerd met javap op vertx-core-4.5.30). Twee
reactieve recover-punten in BerichtensessiecacheService filterden op Exception en lieten dat
type dus door:

Plek Gevolg
aggregeerEnSlaOp de OPHALEN_FOUT-route werd overgeslagen, de fout bereikte de SSE-emitter, RESTEasy kapte de response af zonder afsluitende chunk → curl exit 18, browser TypeError: network error
per-magazijn recover in de bulkhead-taak een Vert.x-Throwable viel door naar het vangnet en werd daar als OVERBELAST ("niet bevraagd") geclassificeerd, terwijl het magazijn wél bevraagd was en faalde

Beide staan nu op het ongetypeerde .onFailure().

De blocking-paden zijn bewust níet aangepast. await().atMost(...) verpakt een
niet-RuntimeException in een CompletionException, die wél een Exception is — dus
catch (e: Exception) is daar correct. Dat is met bytecode-inspectie van Mutiny's
UniBlockingAwait.await bevestigd, en er staat nu een test die die asymmetrie vastpint, zodat een
latere onderhouder ze niet "voor de consistentie" meeverbreedt.

Knoppen

Property Default Wat
berichtensessiecache.redis-batchgrootte 256 Berichten per batch; fail-fast > 0 gevalideerd bij boot
quarkus.redis.max-waiting-handlers 2048 Commando's per connection waarvan het antwoord nog moet komen

Beide sleutels hebben een env-override, zodat een operator niet één kant van de invariant kan
verzetten zonder de andere. De invariant redis-batchgrootte < max-waiting-handlers wordt
bij het opstarten afgedwongen: bij een conflict start de dienst niet, met een melding die beide
sleutels én hun waarden noemt. max-waiting-handlers=2048 is de bestaande Vert.x-default die nu
expliciet is opgeschreven — geen gedragswijziging.

De piek is batchgrootte, niet 2 × batchgrootte: de HSET en de EXPIRE van één bericht zijn
aan elkaar ge-chained, dus ze staan nooit samen in de wachtrij.

Wat de tests bewijzen

De integratietest reproduceert de productiestoring op testschaal: het TestProfile zet
max-waiting-handlers op 64, waardoor 200 berichten al ruim over de grens gaan. Die test is
aantoonbaar rood op de ongewijzigde store
, met Redis waiting queue is full — dat is expliciet
gemeten vóór de fix, niet achteraf beredeneerd.

Daarbij een waarneming die het issue nog niet noemt: een overflow laat de gepoolde connection
midden in een MULTI achter, waarna vervolgoperaties op diezelfde connection stuklopen met
ERR MULTI calls can not be nested. De storing is dus besmettelijker dan alleen de ronde die hem
veroorzaakt.

Mutatietesten

Er zit geen pitest in deze repo, dus elke nieuwe test is handmatig gemuteerd: mutant toepassen,
test draaien, rood bevestigen, terugdraaien, groen bevestigen. Dat geldt ook voor de bestaande
tests waarvan de helper is aangeraakt.

Onderdeel Mutanten Uitkomst
RedisBatching 6 5 gedood; 1 bewezen equivalent (de lege-lijst-guard weglaten: chunked+fold over een lege lijst is een no-op)
store + boot-guard 5 + her-mutatie bestaande guard alle gedood
renewBerichtTtls / pruneListEnDelHash 3 alle gedood; twee tests toegevoegd om ze te kunnen doden (sliding TTL op de list-key, en dubbele list-entry bij delete-prune)
De verbrede foutfilters 4 3 gedood; 1 bewezen equivalent (blocking catch ExceptionRuntimeException, via bytecode van UniBlockingAwait)

Twee mutanten zijn dus niet geforceerd rood gemaakt met een kunstmatige assertie, maar als
equivalent onderbouwd — ze veranderen het waarneembare gedrag niet.

Eén mutatie legde een echte zwakke test bloot: de eerste TTL-test asserteerde op berichten.last(),
maar store sorteert aflopend op publicatietijdstip, dus dat bericht zat juist in de eerste
batch. De test slaagde vacuüm voor precies het scenario dat hij claimde te controleren. Gecorrigeerd
naar het bericht dat werkelijk in de laatste batch valt.

Wat het nalopen van de foutfilters opleverde

De rest van de productiecode is nagelopen op dezelfde bugklasse: 26 filter- en catch-plekken
beoordeeld in fbs-common, fbs-magazijnregister, fbs-berichtensessiecache, berichtenuitvraag
en berichtenmagazijn. Geen enkele bleek te lekken, dus geen wijzigingen. De inventarisatie
staat in het plandocument.

Eén nevenbevinding, buiten scope en niet verslechterd door deze PR: het ongefilterde
getPage-pad roept alleen renewSessionTtl aan, nooit renewBerichtTtls — ongefilterde reads
verlengen de per-bericht-hash-TTL's dus niet. Bestaand gedrag, maar het lijkt de moeite waard om
apart te bekijken.

Verificatie

Suites (alle drie modules, clean verify): fbs-berichtensessiecache 411/411, berichtenuitvraag
246/246, berichtenmagazijn 439/439. JaCoCo 90%-gate gehaald, detekt 0 bevindingen. De
always-run-filter-deprecatiewaarschuwing in het magazijn is pre-existing en niet door deze branch
veroorzaakt.

Demo-meting, tegen de lokale stack met de uitvraag herbouwd uit deze branch (geen dev-mode
surrogaat), persona met honderd aangesloten organisaties:

Organisaties Slotevent curl exit
100 3 van 3 0 (geen exit 18)

Daarmee is acceptatiecriterium 1 gehaald. Het uitblijven van curl exit 18 is expliciet
gecontroleerd: dat was het symptoom uit het issue.

Foutpad. Met het bewaren opzettelijk kapot krijgt de ondernemer:

Resultaten konden niet worden opgeslagen; haal opnieuw op (ref: <uuid>)

De stream sluit daarbij netjes af (curl exit 0, HTTP 200). Acceptatiecriterium 2 gehaald.

Een eerlijke kanttekening bij die tweede meting. De overflow terugbrengen door alleen de
batchgrootte absurd hoog te zetten bleek op demo-schaal niet genoeg: de +QUEUED-acks komen
vrijwel direct terug op een onbelaste lokale Redis, waardoor de wachtrij zich sneller leegt dan hij
zich vult. Reproductie vereiste daarnaast max-pool-size=1 en 50 ms kunstmatige latency. Een
controle-ronde onder exact dezelfde condities maar mét de standaard batchgrootte slaagde wel — dat
isoleert de batcher als de werkzame factor.

Dat versterkt het beeld uit het issue in plaats van het te ondergraven: dat concludeerde zelf al
"geen harde drempel maar een race — of het misgaat hangt af van hoe snel Redis meekomt". Een
onbelaste lokale Redis is nu eenmaal sneller dan productie.

Basisbranch

Deze PR staat op feature/magazijn-bulkhead-wachtrij (#283), niet op main — het probleem werd
pas zichtbaar door de wachtrij uit die PR plus de doorpaginering, en hoort er in dezelfde volgorde
achteraan.

Closes MinBZK/MijnOverheidZakelijk#1077

🤖 Generated with Claude Code

https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y

ericwout-overheid and others added 7 commits September 7, 2026 09:39
Beschrijft de sequentiele batcher in fbs-berichtensessiecache en het
verbreden van twee reactieve foutfilters naar Throwable, met per taak
een mutatietabel en de verificatie op de demo-stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
…ch raken

De test asserteerde op berichten.last() in de aanname dat dat het laatst
verwerkte bericht was, maar store() sorteert vóór het batchen op
publicatietijdstip aflopend — het bericht met de hoogste timestamp landt
dus als eerste in die gesorteerde lijst, in de eerste batch. De test
slaagde daardoor vacuous en bewees niets over gedrag voorbij de eerste
batch. Assert nu op berichten.first() (laagste timestamp, komt als
laatste uit de sortering) en benoem in de comment de echte reden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
renewBerichtTtls en pruneListEnDelHash boden hun EXPIRE- resp. LREM-commando's
nog met Uni.join().all() in één keer aan de connection-wachtrij aan. Beide
zijn vandaag begrensd (renewBerichtTtls door pageSize, pruneListEnDelHash
door 0-of-1 match), maar het onbegrensde fan-out-patroon stond nog in het
bestand en kon terugkeren zodra een van beide lijsten later meegroeit. Beide
gaan nu via dezelfde RedisBatching.inBatches-helper als store, binnen
dezelfde MULTI/EXEC-transactie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
…iken

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

JaCoCo coverage

Overall Project 92.19% 🍏
Files changed 100% 🍏

Module Coverage
FBS Berichtensessiecache Library 93.14% 🍏
Files
Module File Coverage
FBS Berichtensessiecache Library RedisBatching.kt 100% 🍏
BerichtensessiecacheService.kt 92.45% 🍏
BerichtenCache.kt 89.67% 🍏

ericwout-overheid and others added 2 commits September 7, 2026 12:41
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y
…l de documentatie

Voegt een fail-fast guard toe die redisBatchgrootte tegen quarkus.redis.max-waiting-handlers
valideert bij het opstarten, en geeft die laatste sleutel dezelfde env-override-vorm als de
batchgrootte — een operator kan nu geen configuratie draaien die de invariant breekt zonder dat
de service dat meldt. Corrigeert daarnaast drie factual bugs in comments/docs die de eindreview
van fix/redis-store-batchgrootte vond: de in-flight piek per batch is de batchgrootte zelf (HSET
en EXPIRE zijn per bericht met .chain geregen, nooit beide tegelijk in de wachtrij), niet het
dubbele; een ronde van 2700 berichten kost 11 batches à twee round-trips (22 totaal), niet 11
round-trips; en de connectiepool speelt geen rol in het store-pad omdat withTransaction één
connection claimt voor de hele MULTI/EXEC.

Voegt ook een test toe die pint dat een mislukte TTL-verlenging een geslaagde read nooit laat
falen, en breidt de KDoc van RedisBatching.inBatches uit met het contract bij een gefaalde batch
buiten een transactie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oiwkSpLFuoQSt6QnozM5Y

@ericwout-overheid ericwout-overheid left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready for review

@ericwout-overheid
ericwout-overheid marked this pull request as ready for review September 7, 2026 17:35
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