OpenConceptLab/ocl_issues#2849 | Auto Match waits out a busy server (429 Retry-After), bounds $rerank, preview concurrency 2 - #86
Conversation
…nstead of freezing or failing rows A 429 now means "slower", never "broken": - services/capacity.js: requestWithCapacityRetry waits for Retry-After plus 0-50% jitter, however often a 429 comes, until a 30-minute cap; then the call ends "throttled". 502/503/504 and network errors are retried twice, then reported as errors. Stop ends any wait. Never rejects. - One gate per server per tab: a 429 on any request holds back the others, and the bulk scheduler (runWithConcurrency) sends no new batch while it's paused instead of draining the queue into refusals. - At most 2 $rerank calls in flight per tab (a limiter), the per-row reranks after each finished batch included. Moved off APIService.post/get (which never settled on a 429 and resolved a 5xx as its body): $rerank, ScispaCy, the bridge $match (through the service oclmap hands the Bridge Match component, so no component release is needed), row-panel search and facets, and the logs POSTs. Bulk and single-row $match, lookups, $resolveReference and the AutomatchRun create/close calls now wait out 429s too. These calls pass handlesThrottle, so the full-screen "Too many requests" countdown, which covered Stop, stays closed. Rows: - A waiting row shows "Waiting for capacity: results may take longer due to demand", in its panel and in the toolbar. - A row still refused at the cap is throttled (-4): "not run, retry", never failed. The run summary lists those rows apart from failures, and a run with any is partial, not failed. - A 5xx on $rerank marks the rerank failed; before, the row looked reranked with no scores. - A stopped or failed run still autosaves and closes its AutomatchRun; the close PATCH retries and ignores Stop. - The logs POST sends one at a time per project, reading the log when it sends, so a retried older POST can't overwrite a newer one. - A bridge call the component never sends (bridging unavailable) marks the algorithm n/a instead of leaving the row running forever. The X-OCL-Capacity-* headers are parsed and logged; adapting to them is OpenConceptLab/ocl_online#340. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
…ch batches in flight; early access stays at 5 requestLimits.js now caps by the user's highest tier instead of a single non-core cap: core users and staff keep the form's full range, early access 10 rows x 5 requests, and preview (or no tier) 10 rows x 2. Many preview users at once could fill the server's matching capacity. A preview project has at most 25 rows, 3 batches of 10, so 2 in flight costs one more batch's time per run. The form's limit and helper text follow the same tier. Kept as its own commit: the number is still being confirmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
…ed pauses, Stop never waits on a hung send, the sweep waits for queued reranks - A gate wait now has a deadline: other requests' 429s extending the pause can't carry a request past its cap. A Retry-After past the cap still pauses the gate first, so the other requests hold back instead of each asking again. - The scheduler doesn't hold its queue for a pause longer than the cap (those batches end throttled at once), and Stop returns even while a batch in flight never answers (untilCancelled). The rerank sweep now runs on the same scheduler; the bridge and ScispaCy loops watch Stop the same way. - Once a request for an algorithm, or a rerank, stays refused for the whole cap, the run stops asking: the rest of its rows end throttled at once, instead of each waiting out another 30 minutes. - The sweep waits for a row's rerank that the debounced path already queued, then proposes the row's mapping; before, it skipped the row, and with the 2-rerank limit most rows were queued by then. - A batch that answers after a newer run started isn't merged into it, and a superseded run's rerank leaves the new run's stages alone. - A throttled rerank shows "Server busy" in the row panel. - The logs POST keeps its payload per project and sends the snapshot taken when a save started, so a retry can't post another project's log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
65c88fe to
ec0fb65
Compare
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 1 (codex-cli 0.159.2, commit ec304f0)
Scope: git diff origin/main...HEAD at ec304f0, read-only, adversarial prompt covering the ticket's acceptance criteria.
Outcome: fixed in ec0fb65, except item 1, which is only partly taken:
- Hung sends: Stop no longer waits on a send that never answers. The scheduler, the rerank sweep, and the bridge and ScispaCy loops watch Stop while awaiting (
untilCancelled), and in-flight sends finish in the background ("Stopping gracefully"). Not added: a per-attempt client timeout.$matchis metered and can legitimately take minutes, so timing out and retrying it would double-charge and add load. Axios has had no timeout in these paths before either. - A batch that answers after a newer run started isn't merged into it, and a superseded run's rerank leaves the new run's stages alone.
- The sweep awaits a row's already-queued rerank and then proposes the row's mapping. With the 2-rerank limit this was a real regression: the sweep's own call skipped most rows.
- Gate waits have a deadline, so extended pauses can't carry a request past its cap.
- A
Retry-Afterpast the cap pauses the gate before the request gives up. The scheduler doesn't hold the queue for a pause longer than the cap. Once an algorithm (or rerank) stays refused for the whole cap, the rest of its rows end throttled without being sent. - A throttled rerank shows "Server busy" in the row panel.
- Each logs sender keeps its own payload, and the save path sends the snapshot taken when the save started.
On the Bridge Match contract: I read react-bridge-match main (93fc0a5). fetchBridgeCandidates calls service.post synchronously, and its only other call is the one-time get for the bridge repo, which this PR leaves unchanged. The "uncommitted edits" and the interrupted suite Codex mentions were my in-progress work at the time; the full suite passes at ec0fb65.
I would request changes. I reviewed git diff origin/main...HEAD; no files were modified.
-
High — Requests can still hang forever, blocking Stop and leaking rerank slots.
src/services/capacity.js:219
await send()has neither a timeout nor cancellation. The migrated calls supply no Axios timeout or signal. If a connection stays open without answering, Stop cannot settle the request; two such reranks occupy both limiter slots indefinitely. Run finalization may never reach autosave or the closing PATCH. I reproduced a send remaining pending after cancellation.
Fix: Give attempts a finite timeout and an AbortSignal tied to the run. Ensure cancellation settles the wrapper and releases slots. -
High — A stopped batch can write results into a subsequent run.
src/components/map-projects/MapProject.jsx:2310,src/services/capacity.js:249
Cancellation is checked before sending, but not after the response arrives. The scheduler can return on Stop while other requests remain active. Starting another run resetsabortRef; an old successful response then reaches the unguarded candidate merge andsetMatchedConcepts. Only its stage writes have the run-ticket guard. Rerank result writes and cancellation stage writes have similar missing guards atMapProject.jsx:4206and:4247.
Fix: Capture immutable run context and guard every result, stage, log, quota, and mapping side effect. Discard obsolete responses after settlement; drain cancelled work before finalizing. -
High — Run completion does not await reranks already launched by finished batches.
src/components/map-projects/MapProject.jsx:4135,:2477
When the final rerank sweep encounters a row already reranking,rerank()returnsnullimmediately. If the debounced path already queued all rows, the sweep finishes while those requests are still waiting for capacity. The run then clears loading, schedules autosave, and closes its AutomatchRun before scoring finishes. Later scores may miss that save, and Stop can disappear while work remains outstanding.
Fix: Track rerank promises per row and run. Return the existing promise when appropriate, and await all run-owned reranks before saving and closing. -
Medium — Shared-gate waits bypass the 30-minute cap.
src/services/capacity.js:205
The cap is checked against the initial pause, thengate.wait()can follow unlimited extensions from other requests. After it finally opens, elapsed waiting is recorded, but the code can send successfully even after exceeding the cap. My reproduction configured a 2-second cap and obtained success after 8.5 seconds of waiting. Continuous extensions can wait indefinitely.
Fix: Enforce the remaining budget during gate polling and jitter, and check it before sending. -
Medium — A long Retry-After does not pause bulk scheduling.
src/services/capacity.js:230
When Retry-After plus jitter exceeds the remaining budget, the helper returns before callinggate.pause(). For example, a 429 with Retry-After exceeding 30 minutes marks each batch throttled immediately while the scheduler continues sending the remaining queue to the same busy server.
Fix: Publish the server pause before terminating the individual request. Bound the scheduler’s wait separately and retain unscheduled rows without issuing further requests. -
Medium — Rerank throttling is invisible in the row progress label.
src/components/map-projects/rowProgress.js:19
Progress examines only configured matching algorithms. With matching stages all1andrerank: -4, it returns “done,” leaving no retry label despite the rerank being throttled. The new tests cover waiting reranks but omit terminal rerank throttling.
Fix: Include rerank’s running/error/throttled state when deriving row progress, and test the rendered Candidates chip. -
Medium — Retried logs can overwrite one project with another project’s logs.
src/components/map-projects/MapProject.jsx:1728
The sender captures project A’s URL but reads component-wide log refs on every retry. Navigating directly to project B reuses the route component and replaces those refs. A delayed retry for A can consequently POST B’s logs to A. Coalescing prevents overlapping sends within one sender, but does not preserve project ownership.
Fix: Keep log payloads and sender state keyed by project identity; each trigger should update only that project’s payload.
The ordinary Retry-After parsing, jitter, gateway-error backoff, limiter release logic, preview/early-access caps, and unchanged core/staff request limits look fine. Capacity headers are parsed and logged without adaptive behavior, and the new translation keys exist in English, Spanish, and Chinese. Disabling ambiguous-error retries for AutomatchRun creation is also appropriate.
The premium Bridge Match implementation is loaded externally, so its synchronous post() assumption and other service calls remain unverified; the local stub does not prove that contract.
The focused tests passed 174/174, but mostly exercise helpers rather than MapProject’s lifecycle. Existing uncommitted edits in matchBatch.js and its tests were excluded from the PR findings; the test run included those edits. The broader suite did not finish and was interrupted.
…sport timeouts, one refused algorithm no longer blocks the rest, the chain goes on after a failure - Transport timeouts bound a send that never answers: 11 minutes for heavy calls ($match, $rerank, bridge, ScispaCy), longer than the API itself runs a request, so a timeout never cuts short work the server is still doing; 60 s for logs, run records, lookups and search. A hung rerank can't hold a limiter slot, or a hung logs POST the logs, for good. - Stop ends the wait for the AutomatchRun create call too; a run the server opens after that is closed as cancelled. - A Retry-After past the cap no longer pauses the whole server's gate: the e2e check showed one refused algorithm blocking another, the reranks and later runs in the tab for as long. That request gives up alone, and the run stops asking for its algorithm (the rule from pass 1). - The scheduler's hold on a paused gate is 30 minutes in all, however often other requests extend the pause. - The sweep reruns a row after its queued rerank ends, so candidates that arrived meanwhile are scored before the row's mapping is proposed; and a stopped run proposes nothing more. - A superseded run's bridge, ScispaCy and rerank outcomes leave the new run's stages alone. - A save keeps the entries logged while it was in flight, instead of resetting the log to what it held when the save started; a save that lands after another project was opened doesn't touch that project's log. - When ScispaCy or the bridge fails (or stays throttled) for a row matched by hand, the row goes on to its next algorithm and its rerank, as other algorithms do. - The toolbar notice is short in the split view, with the full text in a tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 2 (codex-cli 0.159.2, commit ec0fb65)
Scope: git diff origin/main...HEAD at ec0fb65, read-only; verify the pass-1 fixes, then look for new problems.
Outcome: fixed in bd81b0f.
1, 3 and 7. A send that never answers is now bounded by transport timeouts. Heavy calls get 11 minutes, longer than the API itself runs a request, so a legitimate $match is never cut short or re-metered. Light calls (logs, run records, lookups, search) get 60 s. Stop also ends the wait for the AutomatchRun create call, and a run the server opens after that is closed as cancelled.
2. A superseded run's bridge, ScispaCy and rerank outcomes leave the new run's stages alone, and a stopped run proposes no more mappings. Kept on purpose: a late result after Stop in the same run is still merged, as on main ("Stopping gracefully"). Those candidates are real, and the next save persists them.
4. The sweep reruns a row after its queued rerank ends, so candidates that arrived meanwhile are scored before the row's mapping is proposed.
5. The scheduler's hold on a paused gate is 30 minutes in all.
6. A save keeps the entries logged while it was in flight, instead of resetting the log to its starting snapshot (this predates the PR). A save that lands after another project was opened leaves that project's log alone.
8. When ScispaCy or the bridge fails, or stays throttled, for a row matched by hand, the row goes on to its next algorithm and rerank, like other algorithms. This also predates the PR, for errors.
The local end-to-end check (below, in a separate comment) also found that my pass-1 fix for item 5, pausing the whole server for a Retry-After past the cap, let one refused algorithm block the others, the reranks and later runs for as long. bd81b0f reverts it: that request gives up alone, and the run stops asking for its algorithm only.
On the Bridge Match contract, see pass 1: react-bridge-match main calls service.post synchronously.
Pass 2 still finds blocking issues. I reviewed git diff origin/main...HEAD and ran the focused tests: 187 passed. No files were modified.
-
High — Stop still cannot escape a hung AutomatchRun creation request.
MapProject.jsx:1863, capacity.js:230
createAutomatchRun()directly awaitsrequestWithCapacityRetry(), which directly awaitssend(). Cancellation is checked during backoff, but not during an in-flight send. If the creation POST never answers, Stop leaves the UI stopping indefinitely; execution never reaches the pipeline’sfinally.
Fix: Make awaiting creation cancellable without retrying an ambiguous POST. Handle a late successful creation separately so its returned run ID is closed. Give the idempotent completion PATCH a bounded transport timeout as well; its current wait budget does not bound a hung send. -
High — Stopped or superseded runs can still overwrite current results and decisions.
MapProject.jsx:2614, MapProject.jsx:4024, MapProject.jsx:4259
Stop abandons the outer wait but leaves its request running. A late bridge or ScispaCy success still invokes callbacks that mark stages and merge candidates. Rerank checks supersession only in cancellation/throttle branches; successful responses and terminal errors still update the newer run. Its delayedsetAutoMatched()can overwrite a subsequent manual decision. Ordinary batches guard against a newer ticket, but Stop itself does not invalidate their ticket, so late results can arrive after the stopped run’s autosave and completion counts were captured.
Fix: Capture an immutable run context and guard every result, error callback, stage/log update, and delayed proposal against cancellation and supersession. Apply the guard after every awaited send. -
High — Hung reranks permanently occupy the shared limiter.
MapProject.jsx:4217, MapProject.jsx:4308
Two hung$reranksends hold both slots. Stop lets the sweep return, but neither rerank reachesfinally, so neither releases its slot or settles its row’s in-flight promise. Every subsequent run’s reranks queue forever behind them. Same-row sweep calls also await those old promises.
Fix: Scope limiter and in-flight bookkeeping to the run, with separate manual-request bookkeeping, or implement explicit cancellation of abandoned requests. Preserve the deliberate no-timeout/no-retry policy for metered$match; merely abandoning the sweep is insufficient. -
Medium — The sweep waits for the first rerank, but not a required follow-up rerank.
MapProject.jsx:4153, MapProject.jsx:4311
If candidates arrive while a rerank is running, the sweep awaits that promise and returns.finallysettles the promise and schedules the follow-up through a debounce. The run can finish, invoke AI, autosave, and propose a mapping while the follow-up rerank is still pending. That follow-up usesisBulk=false, so it does not correct the proposal after better scores arrive.
Fix: Make the row’s completion promise encompass required reruns, or have the sweep repeatedly await/recheck until eligible candidates are scored. Propose only after that final completion. -
Medium — Scheduler gate waits still have a renewable 30-minute budget.
matchBatch.js:140
If the scheduler starts while its shared gate is paused, it may have no active request. Other requests can keep extending that pause.gate.wait()eventually returns'timeout', but the scheduler ignores the result and starts another wait with a freshmaxHoldMs. No request has enteredrequestWithCapacityRetry(), so no row sets the run-level throttled flag. The queue can remain pending indefinitely. I reproduced repeated timeout results receiving a fresh budget each time.
Fix: Track a cumulative scheduler deadline and handle'timeout'by marking the queued rows throttled and ending scheduling. -
Medium — A late save can overwrite newer logs for the same project.
MapProject.jsx:1684, autosave.js:173
Save captures log snapshot A. While it is pending, new entries produce snapshot B and trigger its logs POST. When save finishes, it triggers A plus the save entry. The sender treats the latest trigger as the latest content, so the older snapshot replaces B—even during B’s retry.projectLogsRefis also reset to the older snapshot.
Fix: Maintain project-scoped log state with revisions. Merge the save entry into that project’s latest logs, and reject older snapshots instead of ordering solely by trigger arrival. -
Medium — One hung logs POST blocks every subsequent logs update.
MapProject.jsx:1731, autosave.js:157
Serialization introduces a new failure mode: a POST that never settles keeps the sender’s chain active forever. Later log changes only setpending; none can be transmitted. The five-minute retry budget bounds sleeps, not the send itself.
Fix: Bound and cancel the logs transport attempt, then send the latest snapshot. Preserve ordering so an abandoned older request cannot subsequently overwrite newer logs. -
Medium — ScispaCy throttle/error outcomes do not advance the single-row algorithm chain.
MapProject.jsx:3932, MapProject.jsx:4032
fetchAllCandidatesForRow()advances to the next algorithm throughonResponse. ScispaCy invokes that callback only on success. A capacity-cap outcome or terminal error marks ScispaCy and returns directly, leaving subsequent algorithms unrun and preventing the normal rerank continuation.
Fix: Return terminal outcomes through the common callback, or centralize chain advancement so every outcome settles and advances exactly once.
The Pass 1 fixes that are fine: the request-level gate deadline, pausing the gate before rejecting an oversized Retry-After, the throttled-rerank label, and isolating retry payloads by project URL. Preview concurrency is capped at two; early access remains five; core/staff saved request settings remain uncapped. The added translation keys exist in all three locale files, and capacity headers remain observational.
The helper tests do not prove React run isolation, Stop during run creation, limiter recovery after abandoned sends, completion of follow-up reranks, or preservation of logs across late saves. The premium bridge implementation is externally loaded, so its synchronous post() assumption and other service calls remain unverified from this checkout.
…n's rerank scores aren't merged, and the orphan-run close retries - A rerank from a run that a newer run replaced doesn't write its scores: the newer run may rank with another encoder or input, and would otherwise skip those candidates as already scored. - Closing a run the server opened after the user stopped retries a busy server or a gateway error, like the normal close, instead of sending once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 3 (codex-cli 0.159.2, commit bd81b0f)
Scope: git diff origin/main...HEAD at bd81b0f, read-only; verify the pass-2 fixes, then report only new problems of real consequence.
Outcome: both fixed in 0d0221d. A superseded run's rerank no longer writes its scores. The orphan-run close retries like the normal close.
Two new medium-severity findings:
-
Superseded reranks still overwrite the newer run’s scores — MapProject.jsx:4347
Scenario: Stop run A with a rerank in flight, change the input or encoder, and start run B. A’s late success patches B’s current ConceptRows; the superseded check protects only stages. B can then skip those candidates as already scored and propose mappings using A’s scores. A read-only harness executing the actual rerank function reproduced a newer score changing from20to99after A answered.
Fix: CheckisSuperseded()before processing or merging successful results. Preserve graceful late merging only within the same run. -
Orphan-run closure abandons transient failures — MapProject.jsx:1881
Scenario: Stop while AutomatchRun creation is pending. The POST subsequently succeeds, but its cleanup PATCH receives 429 or 503. This path sends once and silently catches rejection, leaving the server record open permanently. Normal run closure correctly uses bounded retries.
Fix: UserequestWithCapacityRetryfor this idempotent cleanup PATCH, with the light transport timeout and bounded capacity wait, independent of Stop.
The affected helper suites pass 190/190 tests, but they do not exercise these lifecycle paths. The transport bounds, cumulative scheduler hold, limiter release, log coalescing, tier limits, translations, and split-view chip look fine. No files were modified.
|
Manual end-to-end check: forced 429s, the real app, a local mock API
A
B
M
T
Not exercised here: ScispaCy and the bridge (their services aren't mocked), and the real 30-minute cap. Unit tests cover the cap with a virtual clock. |
… touch the next run, and a run with throttled reranks is partial - rerank() captures its run when called. After waiting for a row's lookups it checks Stop before touching anything, joins a rerank that started for the row meanwhile, and only clears its own in-flight entry. A stopped run's rerank could otherwise mark a newer run's stage. - A run whose matching finished but whose reranks stayed throttled is closed "partial", not "completed"; the row counts stay as they were. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 4 (codex-cli 0.159.2, commit 0d0221d)
Scope: git diff origin/main...HEAD at 0d0221d, read-only; verify the pass-3 fixes, then report only new problems of real consequence.
Outcome: both fixed in f22d7d7. rerank() captures its run on entry, checks Stop after waiting for lookups, and releases only its own in-flight entry. A run whose reranks stayed throttled closes as partial.
Two new medium-severity findings at 0d0221d:
-
A stopped rerank can overwrite a newer run’s stage. MapProject.jsx:4274
Run A’s rerank waits for pending concept lookups. Stop returns through the scheduler; run B starts before those lookups settle. A then resumes, captures B’s ticket as its supersession baseline, writesrerank: 0, and writesrerank: -1when its original cancellation check rejects acquisition. It can also clear B’s debounce and overwrite its in-flight entry. I reproduced the stage writes using the extracted current function.
Fix: Capture the ticket at function entry, check cancellation immediately after lookup waits and before shared-state mutations, and delete in-flight entries only if they still belong to that invocation. -
Throttled reranks are recorded as completed runs. MapProject.jsx:1924
When retrieval succeeds for every row but$rerankreaches its capacity cap, rows correctly receivererank: -4. However, completion summarization receives only retrieval algorithm IDs, so it ignores rerank throttling and PATCHescompletion_status: 'completed'. Direct reproduction returned{completed_rows: 1, failed_rows: 0, completion_status: 'completed'}for a successfully retrieved, throttled-rerank row.
Fix: Include rerank throttling in terminal-status determination while preserving the intended retrieval-based row counts.
The pass-3 fixes themselves look correct: superseded response scores are blocked before merging, and orphan-run closure retries. All 396 unit tests pass, but they do not cover these two scenarios. No files changed.
… the run's throttle cutoff, and a stale throttled rerank is reset - A rerank checks the run's "stopped asking" rule again once it gets its limiter slot: reranks already queued when another one hit the cap end throttled instead of each starting a fresh 30-minute wait. - A run resets a rerank that an earlier run left throttled, and a row whose candidates all have scores already (inline, from a single-algorithm $match) has its rerank marked done; a successful retry no longer shows "Server busy" or closes the run as partial. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 5 (codex-cli 0.159.2, commit f22d7d7)
Scope: git diff origin/main...HEAD at f22d7d7, read-only; verify the pass-4 fixes, then report only new problems of real consequence.
Outcome: both fixed in 063f85f.
- A rerank re-checks the run's throttle cutoff once it has its limiter slot.
- A run resets a rerank left throttled by an earlier run.
- A row whose candidates are all scored already has its rerank marked done.
Two new findings at f22d7d7:
-
High — queued reranks bypass the run’s throttle cutoff. MapProject.jsx:4289
The throttle check happens before acquiring the limiter slot. When a batch queues ten reranks, the first two can exhaust their 30-minute budgets, but the eight already queued still acquire slots and start fresh retry budgets. Persistent 429s can prolong the run for hours while continuing requests for an algorithm the run supposedly stopped asking for.
Fix: recheck run throttling after acquiring the slot and before sending; mark queued rows-4and release throughfinally. Reproduced using the actualrerankfunction body: the third queued call started after the first had recorded run-level throttling. -
Medium — a successful retry can retain the previous run’s throttled rerank stage. MapProject.jsx:2481, MapProject.jsx:4262
Run initialization resets retrieval stages but preservesrerank: -4. After a throttled run, configure only semantic matching and retry successfully with inline rerank scores. The sweep finds nothing unscored and returns without clearing-4. The row still shows “Server busy,” and pass-4’s summary reports this successful run aspartial.
Fix: reset rerank state for intended rows at run start, and mark rerank complete when all eligible candidates already have scores. Reproduced the stale-stage path and resultingpartialsummary.
The specified pass-4 guards are present and look correct. All 191 targeted tests passed; they don’t cover these two wiring scenarios. No files were modified.
…r Stop are saved, and a superseded batch's preview limit can't stop the next run - A batch, bridge, ScispaCy or rerank result that lands after the user stopped its run is still kept (a graceful stop), and now schedules an autosave: the stopped run's own autosave may already have gone. - A preview-limit refusal from a batch of a run that a newer run replaced no longer sets the newer run's quota stop or opens its dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 6 (codex-cli 0.159.2, commit 063f85f)
Scope: git diff origin/main...HEAD at 063f85f, read-only; verify the pass-5 fixes, then report only new problems of real consequence.
Outcome: both fixed in 891fa8a.
- A result that lands after Stop is still kept, and now schedules an autosave.
- A superseded batch's preview limit is ignored, so it can't stop the newer run.
Two new findings, both medium:
-
Late results after Stop can escape autosave. MapProject.jsx:2579
Stop releases the scheduler and schedules autosave after five seconds. If an in-flight batch returns afterward, its results are still merged at lines 2353–2387, but no further save is scheduled. Reopening the project loses those visible results.
Fix: Schedule autosave when a late result is merged into its stopped, still-current run, including late rerank scores. Preserve the intended graceful merge. -
A superseded batch’s preview-limit response can stop the new run. MapProject.jsx:2325
Stop run A while its request is outstanding, then start run B. If A subsequently returns a preview-limit 403—for example,mapper_custom_algorithms_denied—onPreviewLimitcalls the shared handler without checking A’s ticket. The handler reads B’s bulk-run state, sets B’smatchQuotaStopRef, and drops B’s remaining matching work. Stage and result guards don’t protect this callback.
Fix: GuardonPreviewLimitwithisCurrentRun()before changing quota state or displaying its dialog.
Both paths reproduced in memory using the actual request/scheduler helpers. All 397 unit tests pass, but they don’t cover these lifecycle scenarios.
The three pass-5 fixes are present and look correct. No files modified.
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 7 (codex-cli 0.159.2, commit 891fa8a)
Scope: git diff origin/main...HEAD at 891fa8a, read-only; verify the pass-6 fixes, then report only new problems of real consequence.
Outcome: no change. This is a deliberate decision.
After Stop, a result that lands late is kept (graceful stop), and scoring its unscored candidates is the point of keeping it. main does the same, and it's the path the row panel already takes when a row with unscored candidates is opened. These reranks are:
- idempotent and unmetered;
- held to 2 in flight by the limiter;
- bounded by the transport timeout and the capacity cap.
Carrying a run ticket through merges, lookups and debounce timers would add plumbing to stop work whose results we want. The pass-6 fixes are confirmed.
One new finding:
-
Medium — automatic reranks lose their run identity after Stop. MapProject.jsx:4458, with the call at line 4496.
Scenario: Run two algorithms; let one finish, then Stop while the other is in flight. Once the stopped pipeline clears its bulk flags, the second result lands and is gracefully merged. With both algorithm stages complete,
scheduleRerankstartsrerank(index, false, false). That treats this automatic work as manual: it ignores Stop and the run’s throttle cutoff, allowing fresh$rerankrequests and another capacity wait after the run closed. A delayed lookup completion can trigger the same path.Fix: Carry the originating run ticket through merges, lookup completions and debounce callbacks into
rerank. Check that ticket’s cancellation and throttle state before scheduling or sending. Keep late-result merging and autosaving.Evidence: Executing the extracted scheduler with completed stages and cleared bulk flags produced
rerank(0, false, false). The targeted tests cover helpers, not this React lifecycle path.
The two pass-6 fixes are present and correctly guarded on inspection. All 191 targeted tests passed. No other new consequential findings; no files modified.
…rry on, or close the next run's record, once a new run starts Stop re-enables Auto Match as soon as the stopped run's scheduler returns, and a new run resets abortRef. The stopped run's pipeline could then carry on: into its next algorithm, its bridge or ScispaCy loop, or its AI step (spending AI calls on its rows); and its finally closed the new run's AutomatchRun record with the old run's counts. - Each run checks its own Stop (isRunStopped: the user stopped it, or a newer run started) in every phase: the batch scheduler, the algorithm loop, the bridge and ScispaCy loops, the rerank sweep, the AI step and the end-of-run notices, logs and autosave. - Each run closes the AutomatchRun record it opened, passed explicitly, and marks it cancelled when it was stopped or superseded. - Only the current run clears the shared loading state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 8 (codex-cli 0.159.2, commit 891fa8a)
Scope: git diff origin/main...HEAD at 891fa8a, read-only; report only new problems of real consequence (the pass-7 decision stated as deliberate).
Outcome: fixed in 70497ea.
- Every phase of a run checks the run's own Stop (stopped, or superseded by a newer run): the scheduler, the algorithm loop, the bridge and ScispaCy loops, the sweep, the AI step and the end-of-run tail.
- Each run closes the AutomatchRun record it opened, passed explicitly.
- Only the current run clears the shared loading state.
- Beyond Codex's report: the same
abortRefreset also let a stopped run carry on into its next algorithm, which the same change covers.
Found one new high-severity issue.
-
High — stopped pipeline can resume AI work and close the next run’s record. MapProject.jsx:2405, MapProject.jsx:2528
Scenario: Enable automatic AI analysis, stop run A while matching is waiting, then start run B during A’s one-second delay before AI analysis. The cancelled scheduler clears
loadingMatches, allowing B to start. B resetsabortRef.currentto false, so A’s unguarded continuation entersrunBulkAIAnalysisand processes A’s rows. If B’s AutomatchRun opens before A finishes, A’sfinallycallscompleteAutomatchRun, which reads and clears the sharedautomatchRunRef: A closes B’s record using A’s row counts, while A’s record remains open. It also clears B’s loading state and can consume AI allowance on unintended rows.Suggested fix: Carry A’s captured ticket/cancellation check through every pipeline phase, including the delayed AI transition and AI loop. Capture the AutomatchRun record per invocation and pass it explicitly to completion; only the current run should change shared loading state.
A reduced reproduction using the actual
runWithConcurrencyproduced: old AI starts with ticket B → B’s record opens → old finalization closes B. The added tests do not exercise this component-level overlap.
The 191 relevant tests pass. I found no other new concrete problems; the deliberately accepted late-result and post-Stop rerank behavior is fine. No files changed.
…he AI step's calls, and a superseded ScispaCy answer leaves the panel alone - The run's AI step no longer waits past Stop for the prompt template or a row's AI call that never answers (a 429 from the template fetch never settled): a stopped run goes on to its autosave and closes its record, and the call finishes in the background. The AI Assistant calls' own retry is OpenConceptLab/ocl_online#206. - A ScispaCy answer from a run a newer run replaced no longer clears the alert or the panel's loading state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 9 (codex-cli 0.159.2, commit 70497ea)
Scope: git diff origin/main...HEAD at 70497ea, read-only; verify the pass-8 fix, then report only new problems of real consequence.
Outcome: fixed in 3b2e0e7.
- The AI step's awaits (the prompt template and each row's AI call) no longer hold Stop: the run goes on to its autosave and closes its record, and the call finishes in the background. Out of scope: moving the AI Assistant calls themselves to a capacity-aware retry is OpenConceptLab/ocl_online#206.
- A superseded ScispaCy answer no longer touches the alert or the panel's loading state.
Found two new issues at 70497ea:
-
High — Stop can still hang forever during the AI phase.
MapProject.jsx:2625, also lines 2642 and 5460.
Scenario: Enable automatic AI analysis; let matching finish; return 429 from/prompts/<key>/. Prompt retrieval still uses.get(), andAPIService.sendRequestreturns a never-settling promise on 429. Stop cannot interrupt the await, so the pipeline never reaches its autosave or AutomatchRun close. An unresponsive AI invocation likewise has no transport timeout or cancellable outer await.
Fix: Useservice.requestwith bounded capacity retry and a transport timeout for prompt retrieval. Make both AI-phase awaits cancellable using the captured run’s stop check. Guard detached outcomes against supersession and preserve the invocation’s idempotency key. -
Medium — A superseded ScispaCy response clears the current panel’s loading state.
MapProject.jsx:4104, particularly line 4111.
Scenario: Stop run A while its ScispaCy request is in flight. Start run B and open a row whose candidates are loading. A’s request then succeeds. Its bulk callback correctly rejects the superseded result, but the surrounding success handler still clears the shared alert and callssetIsLoadingInDecisionView(false). B’s panel can show an empty/non-loading state before its request finishes.
Fix: CheckisSuperseded()before success-handler UI mutations; likewise guard the unconditional loading reset inendStopped().
Pass-8’s algorithm-loop checks and explicitly passed AutomatchRun record are correct. Its “only the current run clears loading state” fix remains incomplete.
All 191 focused tests passed; they do not cover these React pipeline scenarios. No files modified.
…k debounce is dropped once a newer run starts A per-row rerank that a run scheduled (300 ms debounce) and that fired after the user stopped it and started another run passed as the new run's work: a long refusal on it could switch off the new run's reranks. The timer now remembers its run and is dropped if a newer one has started; the new run reranks its own rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 10 (codex-cli 0.159.2, commit 3b2e0e7)
Scope: git diff origin/main...HEAD at 3b2e0e7, read-only; verify the pass-9 fixes, then report only new problems of real consequence.
Outcome: fixed in eec23c5. A run's pending rerank debounce remembers its run and is dropped if a newer run has started; the newer run reranks its own rows. No unit test: the path lives in MapProject.jsx's component lifecycle, which the node test runner can't mount. The local end-to-end restart scenario (R) covers Stop followed by a new run.
One new finding at 3b2e0e7:
-
Medium — A pending rerank debounce can adopt the next run and disable its reranks. MapProject.jsx:4530, with ticket capture at MapProject.jsx:4263.
Scenario: Run A finishes a batch and schedules its 300 ms rerank debounce. Stop A and start B before that callback executes. The callback retains A’s
isRunTraffic=true, butrerank()captures B’s ticket. Its supersession checks therefore accept it as B’s work. If this request receives a 429 whose Retry-After exceeds the cap, it sets B’s shared rerank throttle flag; B’s subsequent reranks are skipped as throttled, including rows unrelated to A.I reproduced the ownership error in memory using the actual
scheduleRerankfunction: scheduling under A and firing under B producedrunTraffic: true, capturedTicket: "run-B".Fix: Capture the originating ticket when scheduling and discard run-owned callbacks after supersession, or pass that ticket and its cancellation check into
rerank(). Add a regression covering schedule → Stop → new run → timer fires. This concerns work scheduled before Stop adopting another run; the deliberate late-result non-run reranks remain fine.
The pass-9 fixes look correct: both AI awaits are cancellation-wrapped, and superseded ScispaCy successes leave panel state alone. All 191 targeted tests passed, but they do not cover this debounce/run-transition boundary.
No other new concrete findings. No files modified.
|
Manual end-to-end check, re-run on 70497ea (same setup: the real app, a local mock API, no production traffic) All five scenarios pass: A, B, M and T as before, plus a new one, R (Stop, then restart). R
A, B, M, T
The commits after 70497ea change the AI step (not mocked here), a superseded ScispaCy answer, and a superseded rerank debounce. |
…runs through the current render's scheduler A rerank's completion fired its follow-up through its own, possibly older, closure: after Stop, an encoder change and a new run, the follow-up reranked with the old encoder and passed as the new run's work. It now goes through scheduleRerankRef, the current render's scheduler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 11 (codex-cli 0.159.2, commit eec23c5)
Scope: git diff origin/main...HEAD at eec23c5, read-only; verify the pass-10 fix, then report only new problems of real consequence.
Outcome: fixed in 7519657. A rerank's follow-up goes through scheduleRerankRef.current, the current render's scheduler, not its own closure's. No unit test: this is MapProject.jsx component-lifecycle code.
One new finding at eec23c5:
-
High — A superseded rerank can schedule work for the new run using the old configuration. MapProject.jsx:4466
Scenario: Run A has a rerank in flight. Stop A, change the encoder, and start run B. B’s debounced rerank encounters A’s in-flight entry and sets
rerankRerunNeededRef. When A’s response arrives, its scores are correctly discarded—but itsfinallycalls the old closure’sscheduleRerank(index). That scheduler captures B’s current ticket, so the pass-10 guard accepts it. It then sends and merges scores using A’s encoder or row input. B’s sweep subsequently skips those candidates because they already have scores, potentially proposing mappings from the wrong configuration.Reproduction: An in-memory harness using the actual extracted
rerankandscheduleRerankfunctions produced["OLD-ENCODER", "OLD-ENCODER"]after switching toNEW-ENCODERand ticket 2; the second response wrote score91and marked B’s rerank completed.Suggested fix: Dispatch the completion-triggered rerun through
scheduleRerankRef.current, so it uses the current closure. Preserve ownership of pending rerun flags across runs, and add a regression test covering this completion-after-restart sequence.
The pass-10 fix is fine for timers scheduled before a newer run starts; this finding concerns a timer newly scheduled by an old call’s completion.
All 191 targeted helper tests passed, but they don’t exercise this React closure race. No files were modified.
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review, pass 12 (codex-cli 0.159.2, commit 7519657)
Scope: git diff origin/main...HEAD at 7519657, read-only; verify the pass-11 fix, then report only new problems of real consequence.
Outcome: clean, nothing to change.
No new, concrete findings of real consequence at 7519657.
Verified the pass-11 fix: rerank completion invokes scheduleRerankRef.current(index), using the current render’s scheduler.
Reviewed the diff and prior-pass dispositions; all 191 focused tests passed. Those tests validate the helpers, but don’t prove the full React lifecycle or externally loaded Bridge Match integration.
No files modified.
…alls, so the server's capacity limit can enforce for this Mapper oclapi2's capacity limit (OpenConceptLab/ocl_online#275) refuses only clients that say they wait out its 429s: "capacity_aware": "true" in X-OCL-Event-Metadata (enforce_for=aware, the default). This Mapper waits them out but never said so, so enforcement could never reach it. It now sends the flag on the calls the limit gates: the bulk and single-row $match (not to a custom algorithm's server), the bridge $match and $rerank. The single-row $match sent no attribution headers before; it now sends them, stamped mapper-ui-manual. Found in the overnight integration test of oclapi2#922 with this PR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyAC9YAn5hP3yGSREkSN8m
…which $match calls go to OCL (Codex pass 1 on the flag) A custom algorithm without its own URL falls back to OCL's $match, so its bulk calls carry the flag too, as the single-row path already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TyAC9YAn5hP3yGSREkSN8m
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review of the capacity_aware commits, pass 1 (codex-cli 0.159.2, commit 43649ce)
These commits were added overnight in the integration test of this PR with OpenConceptLab/oclapi2#922 (OpenConceptLab/ocl_online#275). Reviewed: 43649ce (the capacity_aware flag). Posted verbatim; read-only.
-
Low —
src/components/map-projects/MapProject.jsx:2311: bulk fallback to OCL remains unflagged.capacityAware: algo.type !== 'custom'differs from URL routing:getMatchAPIServicefalls back to OCL when a custom algorithm has no URL. A single-algorithm bulk run then sendsreranker: trueto OCL without declaring capacity awareness, bypassingenforce_for=aware. The single-row path correctly flags this fallback. Fix: use!(algo.type === 'custom' && algo.url)for bulk too, and add a regression test. -
Low —
src/services/__tests__/attribution.test.js:243: new tests verify serialization, but not call-site wiring. They would pass if a flag disappeared from any request, or attribution headers leaked onto a custom single-row URL. Failure scenario: future routing changes silently bypass enforcement or introduce CORS failures. Fix: test the built-in/custom URL matrix and flag propagation through bulk, single-row, bridge, and rerank requests.
No significant regression found in the normal configured paths.
What I checked and found correct:
- Bulk: built-in requests go to the selected OCL API;
runMatchBatchwaits out 429s and marks exhausted waits throttled (-4), not failed. - Single-row: built-in requests use
requestSingleMatch; throttled responses reach the callback, clear loading, and continue the row’s remaining algorithms. Custom algorithms with a URL receive neither new attribution header. - Bridge: the local
react-bridge-matchsource forwards the headers intoservice.postwithsemantic: true. Mapper’s overriddenpostretries capacity responses and returns a throttled body that the bridge failure callback handles. The script’s hosting origin does not determine the API request origin. - Rerank: targets OCL’s
$rerank/, waits throughrequestWithCapacityRetry, and handles exhaustion as throttled. - CORS: local oclapi2 settings allow both attribution headers. Single-row requests already require preflight through existing headers. Bulk and bridge already sent attribution headers; adding a JSON field creates no new header requirement. Other API deployments must allow these headers; their deployed configuration was not verified.
- Analytics: single-row requests now deliberately carry
mapper-ui-manual, project/row/algorithm metadata, and no run ID, including when overlapping a bulk run. - Encoding: metadata contains the string
"true", matching the supplied server contract. Flagging ungated lexical calls is harmless under that contract. - CI:
eslint src --ext .jsx,.jspassed.npm run test:allpassed 400 unit tests and 6 integration tests.
Reviewed only 43649ce over 7519657, using local source. No files modified; no network used.
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review of the capacity_aware commits, pass 2 (codex-cli 0.159.2, commit 9d1bb52)
These commits were added overnight in the integration test of this PR with OpenConceptLab/oclapi2#922 (OpenConceptLab/ocl_online#275). Reviewed: 43649ce + 9d1bb52. Posted verbatim; read-only.
No significant findings in 7519657..9d1bb52. Both pass-1 findings are addressed.
Checked and found correct:
- Routing:
matchesOnOCL()matchesgetMatchAPIService(). Custom algorithms without a URL receive the flag on bulk and single-row calls; custom algorithms with a URL do not. - 429 handling: Bulk
$match, single-row$match, bridge$match, and$rerankuse rejecting requests withhandlesThrottle: trueand capacity retries. Exhausted waits reach throttled handling rather than failed-row or frozen-promise handling. - Coverage: No additional gated OCL call site found unflagged among the reviewed paths. Flags on lexical-only OCL calls are harmless under the stated backend gates.
- CORS: Local oclapi2 settings allow both attribution headers and all origins. Custom single-row URLs receive neither new header. Bulk custom URLs retain their existing attribution headers. The local bridge implementation forwards headers through the retry wrapper.
- Analytics: Single-row OCL requests now correctly carry
mapper-ui-manual, project, row, and algorithm metadata, without an automatch run ID. - Serialization/tests:
capacity_awareis the string"true". Tests cover opt-in, omission, manual attribution, and the custom-without-URL routing case. They test the helper rather than actual request wiring; that wiring was checked manually. - CI: ESLint passed;
npm run test:allpassed 401 unit + 6 integration tests; diff whitespace checks passed.
No files modified or network used. CORS and bridge conclusions rely on local source, not deployed configuration.
paynejd
left a comment
There was a problem hiding this comment.
Codex adversarial review across both PRs (codex-cli 0.159.2): OpenConceptLab/oclapi2#922 at 2701b02e + #86 at 7519657
From the overnight integration test of the two PRs as a set (OpenConceptLab/ocl_online#275). Focus: the contract between them. Posted verbatim; read-only. Since then: finding 1 (the capacity_aware flag) is fixed in oclmap#86 43649ce + 9d1bb52, and finding 5 in oclapi2#922 2f399a12. Findings 2–4 are listed for OpenConceptLab/ocl_online#340, before enforcement.
Significant issues remain. The planned shadow rollout is compatible, but switching only runtime config to enforcement will not enforce against this Mapper under the default enforce_for=aware.
-
High — Enforcement prerequisite missing.
oclmap:src/services/attribution.js:64; src/components/map-projects/MapProject.jsx:3811
Nocapacity_awareoccurs anywhere in Mapper’ssrc. The attribution builder omits it, and the interactive single-row$matchpath sends no attribution metadata at all.Failure: changing the API to
mode=enforce,enforce_for=awareleaves all these Mapper calls effectively in shadow: they acquire leases even when lanes are full. Switching toallactivates refusals, including for old tabs that cannot handle them safely.Fix: before #340 activation, merge
"capacity_aware":"true"into metadata for every retry-capable heavy OCL call: bulk and interactive semantic$match,$matchwith inline reranking, and standalone$rerank. Preserve existing metadata; use a JSON string, not booleantrue. Do not rely solely on modifying the builder—the interactive path must actually send its headers. This is an activation blocker, not a blocker for shipping shadow mode. -
High — Ambiguous failures can duplicate heavy work and consumption.
oclmap:src/services/capacity.js:37,260; oclapi2:core/concepts/views.py:1223
Mapper retries network failures and 502/503/504 twice, without request deduplication. An 11-minute browser timeout does not guarantee that every earlier gateway failure means the original operation stopped.Failure: an ALB/gateway returns 504 or the connection breaks while Django continues searching. Mapper sends another
$match; both execute and consume operations if successful. During Redis failure, both are admitted uncounted. Even when Gunicorn actually kills the worker at 600 seconds, the precharged operations have no guaranteed refund because process death bypasses the exception handler.Fix: add server-backed idempotency/deduplication for
$match, including consumption and result recovery; otherwise avoid automatic retries after ambiguous execution failures. Validate the actual ALB timeout alongside Gunicorn’s timeout. The ALB configuration was unavailable locally. -
Medium — The “per-tab” gate and rerank limit reset on project unmount.
oclmap:src/components/map-projects/MapProject.jsx:236,361,1804
Gates and the two-slot rerank limiter are component refs. Unmount does not cancel capacity waits or invalidate the run ticket; cancellation checks only Stop and ticket changes in that component.Failure: start work in project A, navigate to project B, then start another run. A’s waits and outstanding calls can continue, while B creates an independent gate and two more rerank slots. One tab can exceed two concurrent
$rerankcalls and ignore A’s server pause.Fix: keep server gates and the rerank limiter in a tab-scoped service. On unmount, cancel queued work and waits through a component/run cancellation token. Outstanding server work may finish, but must retain its shared limiter slot until settlement.
-
Medium — Capacity refusals consume DRF rate allowance; Mapper conflates the two 429s.
oclapi2:core/concepts/views.py:952,1213; oclmap:src/services/capacity.js:242
DRF throttle checks run before the view’s capacity gate. A request subsequently refused for capacity has already passed—and consumed—the rate allowance. Mapper treats every 429 identically, without inspectingerror_code, scope, or rate-limit headers.Failure: at a five-second capacity delay, one waiting request can make roughly 288 attempts in 30 minutes with average jitter. Repeated runs or parallel calls can burn substantial portions of the standard 5,000/day
$matchallowance without matching anything. Eventually a DRF daily throttle replaces capacity refusals; Mapper still describes the condition as capacity throttling. A minute throttle also pauses unrelated heavy endpoints sharing the server gate.Fix: ensure capacity-refused calls do not retain DRF allowance consumption, using a concurrency-safe design. Classify
error_code === "capacity_exceeded"separately from DRF throttles; retain bounded waiting for both, but show the correct reason and apply appropriately scoped pauses. -
Low —
pausedcan contradict the reason for the long retry delay.
oclapi2:core/capacity/limiter.py:405,460
Retry delay considers any full lane with limit zero, butscopeandpauseddescribe only the first full lane.Failure: the cluster lane is full and the preview tier is paused at zero. The response uses the paused delay of 120 seconds but reports
scope:"api_heavy", paused:false. Mapper currently ignores these fields, so waiting works, but consumers cannot reliably distinguish a configured pause from temporary congestion.Fix: prioritize a zero-limit lane as the reported scope, or define
pausedas “any applicable lane is paused” and expose all blocking scopes.
What I checked and found correct:
- Headers/CORS: all seven emitted names match Mapper’s case-insensitive parser. Decision/tier are strings; counts, limits and suggested concurrency parse as numbers.
Retry-Afterand all seven capacity headers are explicitly exposed through CORS;X-OCL-Event-Metadatais allowed for preflight. Mapper’s optional suggested-spacing header is absent on this API and safely omitted. - Response coverage: gated 200s, capacity 429s and DRF-handled errors after acquisition receive capacity headers. Errors before acquisition—authentication, DRF throttling,
$rerankinput validation—do not. Off mode, ungated calls and preflight have no capacity decision headers. Proxy-generated errors cannot receive Django’s headers. - 429 shape:
detail,error_code, andscopeare strings;pausedis boolean;retry_afteris an integer in seconds. Capacity refusal precedes capability consumption. DRF throttles have a different body and ordinarily no capacity headers. - Retry parsing: numeric seconds and HTTP dates work; zero/past dates get a one-second minimum wait. Missing headers fall back to numeric body seconds, then exponential delays of 5–60 seconds. An invalid present header bypasses body fallback. Huge delays immediately end as throttled without poisoning the shared gate. Header-driven waits receive 0–50% jitter.
- Bounds and Stop: Auto Match accumulates up to 30 minutes of waiting, not total elapsed execution time. Single-row
$matchuses five minutes; interactive$rerankcurrently uses the default 30 minutes. Stop interrupts configured waits within a 250-ms poll and prevents further run retries; already executing server work continues. - Leases: 60 seconds is a renewable lease, not a request duration limit. Healthy 20-second renewal keeps a long call counted. Renewal is atomic across all lanes and never revives expired leases. With default settings, Gunicorn kills an overlong sync request at roughly 600 seconds, before the 900-second renewal ceiling; its count then expires within 60 seconds of the last renewal. If a call survives beyond the renewal ceiling, or renewal is lost during a Redis outage, it eventually continues uncounted. Fail-open therefore provides availability, not a hard concurrency guarantee.
- Mixed versions: old Mapper + new shadow API remains compatible. Old tabs +
awareenforcement remain admitted; old tabs +allcan hang on their legacy 429 path. New Mapper + old API tolerates absent capacity headers and handles ordinary DRF 429s. During a mixed API rollout, old tasks perform uncounted work, so cluster limits cannot be authoritative until every task is upgraded. New workers also converge on runtime config over the cache interval, rather than instantly. - Verification: confirmed both requested HEADs and ran 130 focused Mapper tests: all passed. API behavior was reviewed from source and existing tests; no live Django/Redis/ALB verification was performed. No files were modified and no network was used.
|
Independent Claude review across both PRs (a read-only Claude subagent, run in the overnight integration test of OpenConceptLab/oclapi2#922 + #86, OpenConceptLab/ocl_online#275). Reviewed: oclapi2#922 at 2701b02e, oclmap#86 at 7519657. Posted verbatim. Since then: finding 1 is fixed in oclmap#86 (43649ce, 9d1bb52), and finding 7 in oclapi2#922 (040a6891: skip window 15 s). Findings 2–6 are listed for OpenConceptLab/ocl_online#340, before enforcement. Cross-repo contract review: oclapi2 PR 922 and oclmap PR 86I only read code. No files were changed and no APIs were called. Nothing here breaks the shadow-mode deploy. Findings 1–3 must be fixed before enforcement is turned on. Findings1. HIGH: the Mapper never sends
2. HIGH (only once enforced): the Mapper runs more calls at once than the server allows per user, and the retrying batch starves.
3. MEDIUM: a capacity 429 and a DRF throttle 429 look the same to the Mapper, and capacity retries use up DRF quota.
4. MEDIUM: heavy calls are re-sent after long failures.
5. MEDIUM-LOW: one gate per server ignores
6. LOW: a manual
7. LOW: a single Redis error can lose a long call's lease.
8. NIT: the Mapper parses Checked and found correct
|
Overnight integration test: OpenConceptLab/oclapi2#922 + #86 (2026-10-01)The two PRs were tested together on a local production-like stack:
No merges or deploys. Every decisive row was re-run at the final heads (oclapi2#922 c5282f4a, oclmap#86 9d1bb52; CI green on both). Compatibility (decides the deploy order): both directions work.
The new API in shadow mode:
This Mapper against real capacity 429s (enforce mode, which ships off):
Commits added overnight:
Before enforcement (OpenConceptLab/ocl_online#340), from the test and the reviews:
The full readout, reviews and evidence are on OpenConceptLab/ocl_online#275. |
Makes the Mapper safe for a server-side capacity limit on heavy calls (semantic
$match,$rerank) before the server ever enforces one (OpenConceptLab/ocl_online#275 ships it in shadow mode; enforcement is OpenConceptLab/ocl_online#340). Under load, Auto Match gets slower, never broken. Other 429s (DRF throttles) already reach these paths today.Closes OpenConceptLab/ocl_issues#2849 (via 0d0221d).
What changes
Waiting out a 429 (
src/services/capacity.js, new, dependency-free)requestWithCapacityRetrywaits forRetry-After(header, HTTP date, or the body'sretry_after) plus 0–50% jitter, however often a 429 comes, until a 30-minute cap. It then endsthrottled. A 429 withoutRetry-Afterbacks off from 5 s, doubling to a minute.Retry-Afteris honoured), then enderror. A 500 or 4xx is an error at once.{ok, reason: throttled|cancelled|error}.$rerankto 2 in flight per tab, including the per-row reranks fired as each batch finishes (a 10-row batch used to fire 10 at once). The end-of-run sweep waits for a row's queued rerank, reruns it for late candidates, and then proposes the row's mapping.Retry-Afterlonger than the cap ends that request at once without pausing the whole server, so other algorithms carry on.$matchwork is never cut short or re-metered. Light calls (logs, run records, lookups, search) get 60 s. Stop never waits on a hung send: in-flight sends finish in the background.X-OCL-Capacity-*headers are parsed and logged (in the row log for a wait;console.debugotherwise). Adapting to them is #340.Callers moved off
APIService.post/get(which never settled on a 429 and resolved a 5xx as its body)$rerank: a 429 used to freeze the run; a 5xx made the row look reranked with no scores.$match: its warm-up handling is kept, and its 2-minute warm-up wait now stops on Stop.$match: oclmap hands the Bridge Match component itsservice, whosepost()now waits out 429s and always settles, so no react-bridge-match release is needed.$match, concept lookups,$resolveReference, and AutomatchRun create/close.handlesThrottleoption toAPIService.request, so the full-screen "Too many requests" countdown (which also covered Stop) stays closed for them. Other callers still get it.Rows and runs
-4): "not run, retry", never failed. It isn't saved as a failed response, the end-of-run notice lists it apart from failures, and an AutomatchRun with throttled rows ispartial, notfailed.-3) instead of leaving the row "Running" forever.Preview concurrency (own commit, ec304f0; the number is being confirmed)
requestLimits.jscaps by tier: core and staff keep the full range, early access 10 rows × 5, preview (or no tier) 10 rows × 2.Acceptance criteria (#2849)
service.requestwith a bounded retry that honours 429Retry-Afterand backs off on 502/503/504.Retry-Afterand pauses scheduling while throttled.Retry-After, 503, network failure, Stop during a wait, the rerank bound.$rerankin flight per run, the per-batch reranks included.$matchbatches in flight; early access stays at 5.Tests
npm run test:all: 396 unit + 6 integration, all passing. ESLint (CI's command) is clean.services/__tests__/capacity.test.js(44 tests),__tests__/rowProgress.test.js, and new cases inmatchBatch,autosave,autoMatchRows,attributionandrequestLimits.$match;main;Retry-Afterpast the cap doesn't pause the whole server.🤖 Generated with Claude Code
https://claude.ai/code/session_01LxsPK3oiPfG85myNX2VDSj