Skip to content

feat(uitvraag): de gelijktijdigheidsgrens wordt een wachtrij i.p.v. een zeef - #283

Open
mreuvekamp wants to merge 11 commits into
mainfrom
feature/magazijn-bulkhead-wachtrij
Open

feat(uitvraag): de gelijktijdigheidsgrens wordt een wachtrij i.p.v. een zeef#283
mreuvekamp wants to merge 11 commits into
mainfrom
feature/magazijn-bulkhead-wachtrij

Conversation

@mreuvekamp

@mreuvekamp mreuvekamp commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Issue: MinBZK/MijnOverheidZakelijk#1038

Wat er aan de hand was

Een ondernemer met meer aangesloten organisaties dan de gelijktijdigheidsgrens zag alleen de eerste
twintig; de rest kreeg "tijdelijk niet beschikbaar" terwijl die organisaties nooit bevraagd zijn.

Het issue vroeg zich af of het steeds dezelfde organisaties treft. Dat is nu bevestigd: het is een
vaste blinde vlek.
Drie dingen werkten samen:

  1. Multi.createBy().merging() heeft een default-concurrency van 128 (nagekeken in Mutiny
    3.3.0), dus bij honderd organisaties subscribeerde de merge in één keer op alle honderd
    substreams — honderd permit-pogingen tegen twintig permits.
  2. De enige acquire was tryAcquire(): geen permit betekende onmiddellijk OVERBELAST.
  3. De volgorde is deterministisch (associateLinkedHashMap, register-volgorde), dus de eerste
    twintig wonnen elke ronde.

Wat er nu gebeurt

Laag 1 — de ronde wacht op zichzelf. withConcurrency(max-parallel-per-ronde) op de merge: er
zijn hooguit zoveel bevragingen onderweg en de volgende wordt gepakt zodra er één afgerond is. Die
wachtrij zit in de backpressure van Mutiny zelf — geen extra primitief, geen thread en geen permit
voor een wachtende organisatie. Hiermee verdwijnt de blinde vlek volledig.

Laag 2 — de globale grens wacht ook. De semafoor is gedeeld over sessies, dus zonder ingreep
zou hetzelfde patroon over sessies heen terugkomen. Zonder vrije permit wordt er nu asynchroon
gepolld (25 ms) tot max-wachttijd-ms, zonder een worker-thread te bezetten — het thread-plafond
blijft exact max-concurrent. Waarom pollen en geen FIFO-wachtrij van emitters staat in het plan:
een permit-overdragende wachtrij heeft een race die niet lokaal te sluiten is (een permit toekennen
aan een wachtende die net zijn budget overschreed, lekt hem).

Een verstreken wachtbudget is geen storing. Nieuwe status NIET_OPGEHAALD naast
OK/FOUT/TIMEOUT, met de melding "Nog niet opgehaald: te veel organisaties tegelijk in
behandeling". Alle GESTART-events gaan vooruit, zodat een wachtende organisatie zichtbaar is als
"bevragen…" en niet afwezig.

Knoppen

Property Default Wat
…magazijn-bulkhead.max-concurrent 40 (was 20) Globaal thread-plafond over alle sessies
…magazijn-bulkhead.max-parallel-per-ronde 20 Per ophaalronde tegelijk onderweg
…magazijn-bulkhead.max-wachttijd-ms 15000 Bovengrens op het wachten op een permit

Fail-fast gevalideerd bij boot: alle drie > 0, max-parallel-per-ronde ≤ max-concurrent,
max-wachttijd-ms ≤ 120000 en — gekruisvalideerd in valideerTimeouts() naast de bestaande
timeout-invarianten — max-wachttijd-ms ≥ magazijn-query-timeout-seconds × 1000. De grens hoeft
niet meer mee te groeien met het aantal organisaties van een ondernemer, dus de handmatige 120 in de
demo (compose.yaml + het ZAD-runbook) is vervallen.

Wat de review op deze PR opleverde

Drie dingen aangepast, twee gedocumenteerd:

  1. Permit-lek bij een geannuleerde wachtende. Tussen de geslaagde tryAcquire() in de poll-stap
    en het aanhaken van de release in de mapper erna zat een operator-grens; Mutiny levert een item
    niet meer af aan een geannuleerde subscriber, dus de release-tak werd dan overgeslagen en die
    permit was permanent kwijt (geen reaper). De poll-lus draagt de taak nu zelf mee, zodat acquire
    en release weer in één synchroon blok staan. Een test annuleert nu ook een wachtende.
  2. Het wachtbudget was korter dan de per-magazijn query-timeout (5 s tegen 10 s). Een bevraging
    die aanklopt terwijl alle permits door trage calls bezet zijn, gaf het dan op vóórdat er één
    permit kón vrijkomen. Default naar 15 s plus de kruisvalidatie hierboven.
  3. Het poll-interval schaalt nu met het budget (hooguit 200 stappen), zodat de Uni-keten kort
    blijft als een operator het budget optrekt.
  4. Gedocumenteerd: een ronde duurt nu in het slechtste geval
    ⌈organisaties ÷ max-parallel-per-ronde⌉ × query-timeout in plaats van ongeveer één
    query-timeout. Bij honderd organisaties is dat 50 s, ruim binnen de PT2M van
    aggregation-lock-ttl; voorbij ~240 organisaties zou die lock middenin een ronde verlopen. De
    rekensom staat in de operator-handleiding — een startup-controle kan het niet, want het aantal
    organisaties is bij boot niet bekend.
  5. Gedocumenteerd: waarom er geen grens op de wachtrij-diepte zit (elke wachtende is al in tijd
    begrensd, en het aantal volgt uit max-parallel-per-ronde × gelijktijdige rondes).

Doorlooptijd

Een permit komt vrij zodra een wíllekeurige call termineert, niet pas als de traagste klaar is. In
de gemeten verdeling (91 van 100 antwoorden binnen ~100 ms, enkele op de query-timeout van 10 s)
houden de trage calls een handvol permits bezet terwijl de rest de snelle calls in een fractie van
een seconde afwerkt. "Compleet" blijft dus bepaald door de organisatie die niet reageert — precies
zoals nu.

Tests

  • MagazijnAggregatieBulkheadTest: wachten tot een permit vrijkomt, verstreken budget → de
    verlopen-tak, géén permit-lek na een verstreken budget, "alle wachtenden komen aan de beurt",
    "nooit meer taken tegelijk dan permits", en de config-validatie inclusief de nieuwe invariant.
  • BerichtensessiecacheServiceTest: @ParameterizedTest met 5, 6 en 50 organisaties tegen een
    grens van 5 — alle organisaties krijgen een GESTART- én een geslaagd VOLTOOID-event, geen enkele
    mislukking, en alle GESTART-events komen vóór de eerste uitkomst. Dat is het acceptatiecriterium
    "geen enkele organisatie valt structureel buiten beeld"; vóór deze wijziging faalt die test met
    45 afwijzingen.
  • ./mvnw clean verify -pl services/berichtenuitvraag,libraries/fbs-berichtensessiecache -am:
    groen, JaCoCo-gate gehaald (de bulkhead 41/41 regels), detekt 0 bevindingen, geen nieuwe
    build-warnings. shellcheck -x -S warning demo/smoke.sh schoon.

De meting

demo/meet-fanout.sh 3 tegen de lokale demo-stack, op de standaardinstellingen (de handmatige
120 is weg). Mediaan over drie rondes:

Ondernemer Organisaties Eerste bericht Compleet Geslaagd
kleine-eenmanszaak 3 55 ms 0,12 s 3 van 3
klein-bedrijf 15 24 ms 2,8 s 15 van 15
grootbedrijf 45 24 ms 10,1 s 41 van 45
landelijk-concern 100 122 ms 10,3 s 91 van 100

Vóór deze wijziging gaf diezelfde standaardinstelling 20 van 45 en 20 van 100. Nu komen de aantallen
exact uit op de tabel van MinBZK/MijnOverheidZakelijk#1012, die mét de knop op 120 gemeten is — de
knop is dus overbodig geworden en niet vervangen door verlies. Wat niet slaagde is wat de simulator
opzettelijk stuk zet (33 FOUT en 6 TIMEOUT over alle rondes samen, geen enkele
NIET_OPGEHAALD
). "Compleet" hangt net als voorheen aan de organisatie die niet reageert: 45 en
100 komen allebei op 10,1 s uit, dus de wachtrij kost geen meetbare doorlooptijd. In de ronde met
honderd organisaties staan alle 100 GESTART-events vóór het eerste VOLTOOID-event.

Opnieuw gemeten na het samenvoegen met #282

De tabel hierboven is van vóór het doorpagineren: één call per organisatie, en een lege demo — elke
organisatie antwoordde met nul berichten. Nu staat de demo op pagina's van vijf en zet de
standaardvulling 27 berichten per organisatie, dus zes opeenvolgende calls per organisatie. Beide
toestanden opnieuw gemeten, drie ronden, mediaan.

Lege magazijnen — vergelijkbaar met de tabel hierboven, en dezelfde aantallen:

Ondernemer Organisaties Eerste bericht Compleet Geslaagd
kleine-eenmanszaak 3 18 ms 0,07 s 3 van 3
klein-bedrijf 15 21 ms 2,1 s 15 van 15
grootbedrijf 45 24 ms 6,2 s 41 van 45
landelijk-concern 100 21 ms 7,0 s 90 van 100

Gevulde magazijnen — de normale demo-toestand:

Ondernemer Organisaties Eerste bericht Compleet Geslaagd
kleine-eenmanszaak 3 40 ms 0,35 s 3 van 3
klein-bedrijf 15 27 ms 10,0 s 13 van 15
grootbedrijf 45 19 ms 10,0 s 37 van 45
landelijk-concern 100 20 ms geen slotevent 81 van 100

Nog steeds geen enkele NIET_OPGEHAALD: de wachtrij is niet de bottleneck. Twee dingen zijn wél
anders, allebei van het doorpagineren:

  1. Zes calls per organisatie kosten de trage organisaties hun ronde. Bij vijftien organisaties
    gaat het van 15 van 15 in 2,8 s naar 13 van 15 op de query-timeout; de twee die afvallen zijn
    TIMEOUT en geen FOUT. Op deze opstelling draait één simulator alle 98 magazijnen op één
    PostgreSQL, dus dit hangt aan de demo-instelling (pageSize 5) en aan de opstelling.
  2. Bij honderd organisaties breekt de ophaalronde af op het wegschrijven naar Redis. Zie
    hieronder — dat is een bevinding, geen meetresultaat.

Bevinding: de cache-schrijf schaalt niet mee met de fan-out

Alle honderd organisaties worden bevraagd en alle VOLTOOID-events komen door; daarna faalt
RedisBerichtenCache.store met Redis waiting queue is full en eindigt de SSE-stroom zonder
ophalen-gereed. Twee van de drie ronden liepen zo.

store zet per bericht twee commando's (HSET + EXPIRE) in één transactie en biedt ze in één keer
aan (Uni.join().all). Honderd organisaties × 27 berichten is ruim 4300 commando's tegelijk, tegen
de 2048 die de Vert.x-Redis-client in zijn wachtrij toelaat (quarkus.redis.max-waiting-handlers).
Gemeten: 945 berichten (45 organisaties) gaat altijd goed, 2100–2300 berichten (100 organisaties)
tweemaal van de drie mis — een race, want de client verwerkt tijdens het aanbieden ook al.

max-waiting-handlers is een client-instelling en geldt in productie net zo goed, dus dit is geen
demo-eigenschap. Deze PR is wél wat het zichtbaar maakt: eerder kwamen er maar twintig organisaties
door, en die pasten er ruim binnen. Twee richtingen — de knop verhogen (verplaatst de grens) of
store in delen aanbieden (houdt hem onafhankelijk van de fan-out, raakt de transactie-semantiek) —
en geen van beide zit in deze PR.

Tweede reviewronde (vijf agents op de diff)

Drie manieren waarop één lokale fout de héle ophaalronde kon meenemen — substream faalt, merge
annuleert siblings, eind-aggregatie nooit gesubscribed, dus geen slotevent en status BEZIG tot de
lock-TTL:

  1. De wachtstap kon zelf falen. delayIt() maakt van een RejectedExecutionException (dode
    scheduler bij pod-shutdown) een gewone Uni-failure, en die viel buiten élke recover — de
    recover in de taak dekt alleen wat binnen de taak ontstaat. Er ligt nu een vangnet om de hele
    bevraging.
  2. registreerCircuit stond ná die recover, dus een bug in de fóutregistratie nam de ronde mee.
  3. De half-open probe lekte bij annulering. Hij wordt door niets anders gewist dan een terminale
    melding, en het venster groeide met de wachtrij van microseconden naar seconden; een verloren
    probe sloot dat magazijn tot de herstart uit met een CIRCUIT_OPEN die niets over dát magazijn
    zei. De melding hangt nu aan de terminatie.

Verder: de per-ronde-grens is een operatie van het bulkhead geworden (ronde(...)) zodat een tweede
aggregatiepad hem niet kan vergeten; de kruisvalidatie deelt in seconden in plaats van te
vermenigvuldigen (dezelfde overflow-reden die drie regels hoger al stond) en noemt nu ook het
plafond; max-wachttijd-ms is env-instelbaar geworden, symmetrisch met
MAGAZIJN_QUERY_TIMEOUT_SECONDS. Zes tests erbij, plus comment- en doc-correcties (het
thread-plafond is een orde en niet exact, de eerlijkheidsclaim gold binnen een ronde en niet tussen
sessies, en de worst-case-som van een ronde mist het wachtbudget niet meer — die valt daarmee op
125 s in plaats van 50 s, nét buiten de PT2M van de ophaal-lock).

Bewust niet gedaan, met reden:

  • Geen derde teller naast geslaagd/mislukt. Een verstreken wachtbudget telt nu als mislukt,
    waardoor logboekStatusVoor de verwerking in het Logboek Dataverwerkingen op ERROR zet —
    strikt genomen onjuist, want capaciteitsbeleid is geen verwerkingsfout. Een derde teller raakt
    echter het SSE-contract, de aggregatiestatus in Redis, demo/meet-fanout.sh (die de tellingen
    kruiscontroleert) en de berichtenbox-weergave, voor een toestand die alleen bij aanhoudende
    verzadiging over sessies heen voorkomt. Losgetrokken als vervolgwerk.
  • Het bulkhead kent de query-timeout niet, dus de vierde invariant staat in
    valideerTimeouts() en niet in zijn eigen init. Alternatief zou een tweede lezer van
    magazijn-query-timeout-seconds maken terwijl álle timeout-ordening van deze service nu op één
    plek staat. Prijs: één getter naar buiten.

Wat nog open staat

  • Het cache-schrijfpad bij honderd organisaties — zie de bevinding hierboven, losgetrokken als
    Ophalen breekt vlak voor de finish af bij een ondernemer met veel aangesloten organisaties MijnOverheidZakelijk#1077. Zolang die er staat, breekt de ophaalronde van de grootste
    persona op een gevulde demo af zonder slotevent.
  • De meting herhalen op de gedeelde omgeving (stap 9 van verify-zad.md), zoals #1012 dat ook
    deed. De cijfers hierboven komen van één machine waarop de hele stack draaide.
  • NIET_OPGEHAALD is een toevoeging aan de SSE-stroom die in geen API-contract staat. De
    demo-console rendert onbekende statussen netjes (status + foutmelding); de berichtenbox uit de
    proeftuin is niet nagekeken. De status treedt alleen op bij aanhoudende verzadiging over sessies
    heen, dus in de demo hoort hij niet voor te komen.
  • Een oude BERICHTENSESSIECACHE_MAGAZIJN_BULKHEAD_MAX_CONCURRENT=120 op het ZAD-component
    uitvraag
    moet met de hand weg (zadctl env unset); die staat in de OM-projectspec en niet in
    deze repo. Het runbook zegt het nu ook.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen

…en zeef

Een ondernemer met meer aangesloten organisaties dan de gelijktijdigheidsgrens
zag alleen de eerste twintig; de rest kreeg "tijdelijk niet beschikbaar" terwijl
die organisaties nooit bevraagd zijn. Bevestigd als vaste blinde vlek en niet als
wisselende deelverzameling: de merge subscribeerde in één keer op alle
substreams (Mutiny's default-concurrency is 128), de enige acquire was
`tryAcquire()`, en de volgorde is register-volgorde.

Twee lagen lossen dat op:

- Per ronde subscribet de merge op maximaal `max-parallel-per-ronde` bevragingen
  en pakt de volgende zodra er één afgerond is. Die wachtrij zit in de
  backpressure van Mutiny zelf en kost geen thread en geen permit.
- De globale semafoor wacht nu ook: zonder vrije permit wordt er asynchroon
  gepolld tot `max-wachttijd-ms`, zonder een worker-thread te bezetten, zodat het
  thread-plafond exact `max-concurrent` blijft.

Een verstreken wachtbudget krijgt een eigen woord op de lijn (`NIET_OPGEHAALD`
naast OK/FOUT/TIMEOUT) plus een melding die "dit deel ontbreekt nog" zegt in
plaats van te suggereren dat de organisatie eruit ligt. Alle GESTART-events gaan
vooruit, zodat een wachtende organisatie zichtbaar is als "bevragen…" en niet
afwezig.

De grens hoeft daarmee niet meer mee te groeien met het aantal organisaties van
een ondernemer, dus de handmatige 120 in de demo (compose + ZAD-runbook) is
vervallen.

Ontwerp en afwegingen: docs/plans/2026-09-03-magazijn-bulkhead-wachtrij.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen
De eerste ronde-test bewees alleen dat er niets wegvalt, en dat deed het
wachtbudget van de globale semafoor ook al: zonder de per-ronde-grens bleef hij
groen. De nieuwe test zet het wachtbudget krap ten opzichte van de ronde (50
organisaties van 30 ms bij 5 tegelijk, budget 100 ms), zodat hij alleen slaagt
als de merge de wachtenden pas subscribet wanneer er een permit vrij is.
Gecontroleerd dat hij zonder `withConcurrency(...)` daadwerkelijk faalt.

Verder: de KDoc claimde ten onrechte dat de poll-recursie geen keten opbouwt —
die groeit wel, begrensd door wachtbudget ÷ poll-interval; dat staat er nu bij.

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

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

JaCoCo coverage

Overall Project 92.11% -0.1% 🍏
Files changed 95.69% 🍏

Module Coverage
FBS Berichtensessiecache Library 92.89% -0.32% 🍏
Files
Module File Coverage
FBS Berichtensessiecache Library MagazijnEvent.kt 100% 🍏
MagazijnAggregatieBulkhead.kt 100% 🍏
MagazijnResult.kt 95.49% 🍏
BerichtensessiecacheService.kt 92.46% -1.24% 🍏

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

demo/meet-fanout.sh met drie rondes tegen de lokale stack, zonder de handmatige
gelijktijdigheidsgrens: 41 van 45 en 91 van 100 organisaties, gelijk aan de
tabel die eerder mét de knop op 120 gemeten is. Geen enkele NIET_OPGEHAALD, en
"compleet" hangt net als voorheen aan de organisatie die niet reageert (10,1 s bij
zowel 45 als 100), niet aan het aantal — de wachtrij kost geen meetbare
doorlooptijd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen
mreuvekamp and others added 2 commits September 3, 2026 16:20
…ll-keten

Drie bevindingen uit de review op deze PR, en twee dingen die alleen documentatie
nodig hadden.

De permit lekte als een wachtende geannuleerd werd tussen de geslaagde
`tryAcquire()` en het aanhaken van de release: Mutiny levert een item niet meer
af aan een geannuleerde subscriber, dus de release-tak werd overgeslagen en die
permit was permanent kwijt. De poll-lus draagt nu de taak zelf mee, zodat acquire
en release weer in één synchroon blok staan — zoals vóór de wachtrij. Een test
annuleert nu ook een wáchtende, niet alleen de permit-houder.

Het wachtbudget stond met 5 s korter dan de per-magazijn query-timeout van 10 s.
Een bevraging die aanklopt terwijl alle permits door trage calls bezet zijn, gaf
het dan op vóórdat er ook maar één permit kón vrijkomen — voor haar was de
wachtrij alsnog een zeef. Default naar 15 s, en de verhouding wordt bij startup
gekruisvalideerd naast de bestaande timeout-invarianten.

Het poll-interval schaalt nu met het budget (hooguit 200 stappen), zodat de
`Uni`-keten kort blijft ook als een operator het budget optrekt; het plafond kan
daardoor naar de vangnet-TTL van de ophaal-lock in plaats van een willekeurige
minuut.

Verder gedocumenteerd: dat een ronde nu meeschaalt met de fan-out en welke som je
naloopt voordat die de ophaal-lock overleeft, en waarom er geen grens op de
wachtrij-diepte zit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen
Verificatie-sectie liep achter op de tests en commando's zoals ze nu zijn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen
Uit de review op deze PR kwamen drie paden waarlangs een fout in één bevraging
de héle ophaalronde omtrok: de substream faalde, de merge annuleerde zijn
siblings, en de eind-aggregatie werd nooit gesubscribed — geen slotevent, niets
opgeslagen, status BEZIG tot de lock-TTL en dus minutenlang 409 voor de
gebruiker.

- De wachtstap kon zelf falen. `delayIt()` vertaalt een RejectedExecutionException
  (dode scheduler bij pod-shutdown) naar een gewone Uni-failure, en die viel
  buiten élke recover — de recover in de taak dekt alleen wat binnen de taak
  ontstaat. Er ligt nu een vangnet om de hele bevraging.
- `registreerCircuit` stond ná die recover, dus een bug in de fóutregistratie
  nam de ronde mee. De circuit-melding kan niet meer terugslaan op de bevraging.
- De half-open probe lekte bij annulering. Hij wordt door niets anders gewist dan
  een terminale melding, en het venster groeide met de wachtrij van microseconden
  naar seconden; een verloren probe sloot dat magazijn tot de herstart uit met een
  CIRCUIT_OPEN die niets over dát magazijn zei. De melding hangt nu aan de
  terminatie, idempotent met het normale pad.

Verder uit de review:

- De per-ronde-grens is een operatie van het bulkhead geworden (`ronde(...)`) in
  plaats van een los getal dat de aanroeper zelf moet toepassen; een tweede
  aggregatiepad kan de grens nu niet meer vergeten.
- De kruisvalidatie deelt in seconden i.p.v. te vermenigvuldigen naar
  milliseconden — dezelfde overflow-reden die drie regels hoger al staat — en de
  melding noemt nu ook het plafond, zodat een te hoge query-timeout niet twee
  deploys kost om te herkennen.
- `max-wachttijd-ms` is env-instelbaar geworden, symmetrisch met
  MAGAZIJN_QUERY_TIMEOUT_SECONDS: zonder die knop kan een operator die aan de
  query-timeout draait de pod niet meer aan de praat krijgen.
- De weergavenaam wordt nog één keer per ronde opgehaald in plaats van twee keer.
- Tests erbij: de kruisvalidatie (falend én exact op de grens), dat de bevraging
  haar wachtbudget écht volmaakt, annuleren diep in de poll-lus, NIET_OPGEHAALD
  in de wire-contracttabel, naam-correspondentie tussen de twee status-enums, en
  de kruis-invariant dat een niet-bereikt magazijn nooit als storing telt.
- Comment- en doc-correcties: het thread-plafond is een orde en niet exact (de
  query-timeout onderbreekt de blokkerende call niet), de eerlijkheidsclaim gold
  binnen een ronde en niet tussen sessies, de worst-case-som van een ronde mist
  het wachtbudget niet meer, en de MagazijnClient is niet gegenereerd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeaHUXXf3otyqemsxmhUen
De gelijktijdigheidsgrens gaat naar 50, zowel globaal als per ophaalronde. De
ondernemer met honderd organisaties laat daarmee de wachtrij zien (twee golven)
en die met vijfenveertig niet — dat is de grens waarop de demo wil landen.

Per-ronde gelijk aan globaal betekent wel dat één ondernemer de volle capaciteit
mag pakken en een tweede gelijktijdige ronde meteen wacht. Wil je twee rondes
naast elkaar op volle snelheid, dan is max-concurrent de knop die omhoog moet.

Een wachtrij is per constructie stil — wie in de rij staat doet niets, en juist
dat wil je kunnen zien. Daarom logt de ronde zichzelf:

- op INFO, twee regels per ophaalronde, zonder configuratie zichtbaar:
  "Ophaalronde: 100 bevragingen, 50 tegelijk, 50 in de wachtrij" en
  "Ophaalronde afgerond in 10014 ms: 100 van 100 organisaties bevraagd (91
  geslaagd, 9 niet)". Dat tweede getallenpaar is het bewijs dat niemand wegvalt;
  loopt het uiteen, dan is dat een bevinding.
- op DEBUG, per organisatie: wanneer de wachtrij haar oppakte (gehangen aan de
  onSubscription van haar substream, precies dat moment) en of ze op een permit
  moest wachten. De eerste vijftig staan op ~1 ms, de rest schuift op tot 284 ms
  naarmate er plekken vrijkomen.

De permit-regels blijven bij één ronde leeg — binnen een ronde is er per
definitie een permit vrij op het moment dat de wachtrij iemand oppakt. Ze komen
pas in beeld bij gelijktijdige ophaalrondes. Nagemeten in de demo-stack: 0 regels
bij één ronde, zoals gedocumenteerd.

Nagemeten of een grotere golf de gesimuleerde magazijnen overvraagt: bij 100
organisaties 91 van 100 in 10,1 s, gelijk aan de meting bij twintig; de
ondernemer met vijftien wordt sneller (1,3 s tegen 2,8 s) omdat die nu in één
golf past. De cijfers staan in het plan, met de waarschuwing dat een koude eerste
ronde (45 van 100) daar niets over zegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqwSAsiugVvYkJFws6LMeY
@mreuvekamp
mreuvekamp marked this pull request as ready for review September 4, 2026 11:28
De wachtrij en het doorpagineren raken elkaar op drie plekken:

- De demo hoefde de gelijktijdigheidsgrens niet meer op te rekken, dus die
  knop is uit compose.yaml en de runbook-valkuil weg; de paginagrootte van
  main blijft er wél staan.
- De service kent nu naast het bulkhead ook een `paginaLezer`; de tests die
  hier bijkwamen bouwen hem mee.
- De fan-out-tests stubten per call een nieuw bericht zonder tellers. Met de
  pagineerlus las die stub eindeloos door tot zijn tijdsbudget op was — nu
  levert hij één complete pagina, zoals een magazijn dat doet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DqwSAsiugVvYkJFws6LMeY
De oude tabel is van vóór #282: één call per organisatie, en een lege demo.
Beide toestanden opnieuw gemeten. Leeg levert dezelfde aantallen als eerder;
gevuld kost zes calls per organisatie de trage organisaties hun ronde, en bij
honderd organisaties breekt de ronde af op het wegschrijven naar Redis — een
bevinding die hier als vervolgwerk staat, niet als meetresultaat.

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

@ericwout-overheid ericwout-overheid left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NIET_OPGEHAALD staat in geen enkel contract — hoe komt een afnemer het te weten?

Deze PR voegt een nieuwe waarde toe aan de woordenlijst op de SSE-lijn (MagazijnStatus). Een afnemer kan die waarde vandaag nergens vinden: de payload van GET /berichten/_ophalen staat niet in de OpenAPI-spec.

# services/berichtenuitvraag/src/main/resources/openapi/berichtenuitvraag-api.yaml:97
            text/event-stream:
              schema: { type: string }

Dat is een opake string. OK/FOUT/TIMEOUT stonden er dus ook nooit in — deze PR slaat geen nieuw gat, hij maakt een bestaand gat zichtbaar. De feitelijke bron is nu MagazijnEvent.kt plus wat proza in de description van het endpoint, en die proza noemt alleen afgekapt, OPHALEN_GEREED en "de per-magazijn FOUT-events".

Gevolg: zonder gepubliceerde uitbreidbaarheidsregel is elke nieuwe statuswaarde een breaking change voor een afnemer die exhaustief op de bekende waarden matcht.

Onze eigen afnemer leest het al verkeerd

demo/demo-console/src/main/resources/META-INF/resources/berichtenbox.js (regel 183-191) crasht niet — het rendert status letterlijk — maar de slotregel telt via gebeurtenis.mislukt, en die teller bevat NIET_OPGEHAALD: naarVoltooidEvent verhoogt mislukt voor elke MagazijnResult.Failure, ongeacht de fout-status.

De KDoc van MagazijnStatus zegt dat een portaal dit moet tonen als "dit deel ontbreekt nog" en niet als een organisatie die eruit ligt. In de samenvattingsregel gebeurt precies dat laatste. Het onderscheid dat de PR belangrijk noemt, komt bij de afnemer dus niet aan.

Voorstel

# Maatregel Waarom
1 SSE-events als echte schemas in components/schemas (oneOf met event als discriminator), gerefereerd vanaf text/event-stream, inclusief een MagazijnStatus-enum met NIET_OPGEHAALD De woordenlijst wordt gepubliceerd contract in plaats van Kotlin-broncode
2 Uitbreidbaarheidsregel in de description: een onbekende status betekent "niet geleverd, opnieuw proberen kan helpen" en nooit OK Maakt toekomstige waarden non-breaking
3 Guard-test die de enum in de spec vergelijkt met MagazijnStatus.entries Zonder die test rot de spec weer weg zoals nu
4 Eigen nietOpgehaald-teller op ophalen-gereed, of expliciet documenteren dat mislukt hem bevat Anders kan een portaal het onderscheid in de slotregel niet maken
5 berichtenbox.js bijwerken Onze referentie-afnemer hoort punt 2 en 4 voor te doen

Let op dat de bestaande gates dit niet vangen: de Spectral-lint blijft groen bij een toegevoegde enum-waarde, en swagger-request-validator valideert geen SSE-body. Punt 3 is daarom geen luxe.

Vraag

Doen we 1 t/m 5 in deze PR, of wordt het een apart issue onder #349 en houdt deze PR zich bij de wachtrij? Dat tweede is inhoudelijk schoner — het gat is ouder dan deze wijziging — maar dan gaat NIET_OPGEHAALD wel live als niet-gedocumenteerde waarde. Punt 4 en 5 raken wél rechtstreeks het gedrag dat deze PR introduceert; die zou ik hoe dan ook hier oppakken.

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.

Een ondernemer met veel aangesloten organisaties ziet er maar twintig

2 participants