From ce79acb52c9c6099e1297c6f8e00ff7cfe2b1dc9 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Wed, 21 Oct 2026 09:44:17 +0530 Subject: [PATCH 1/2] OpenConceptLab/ocl_online#517 | Mapper settings checkpoint, runs follow the server's suggested concurrency, output language by permission, exact group names --- src/common/utils.js | 14 +-- src/components/map-projects/MapProject.jsx | 16 ++- .../__tests__/aiVisibility.test.js | 21 ++-- .../map-projects/__tests__/matchBatch.test.js | 42 +++++++ .../__tests__/requestLimits.test.js | 106 +++++++----------- src/components/map-projects/aiVisibility.js | 16 +-- src/components/map-projects/autoMatchCheck.js | 12 +- src/components/map-projects/matchBatch.js | 12 +- src/components/map-projects/requestLimits.js | 56 +++++---- src/services/__tests__/capacity.test.js | 35 ++++++ src/services/capacity.js | 24 +++- 11 files changed, 204 insertions(+), 150 deletions(-) diff --git a/src/common/utils.js b/src/common/utils.js index 7eef74b2..b2e7c3a2 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -434,17 +434,9 @@ export const getFHIRServerConfigFromCurrentContext = () => { return getLocalFHIRServerConfig() || getDefaultFHIRServerConfig(); } -export const canSwitchServer = () => { - const user = getCurrentUser(); - - return Boolean( - getSelectedServerConfig() || - get(user, 'is_superuser') || - hasAuthGroup(user, 'server') - ); -} - -export const hasAuthGroup = (user, groupName) => Boolean(find(user?.auth_groups, group => group.includes(groupName))) +// Matches the group's exact name, never a group whose name contains it +// (OpenConceptLab/ocl_online#517). +export const hasAuthGroup = (user, groupName) => Boolean(user?.auth_groups?.includes(groupName)) export const canViewOperationsPanel = () => { const user = getCurrentUser() diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index f7fbf801..de3e7985 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -91,7 +91,7 @@ import ConfigurationForm from './ConfigurationForm' import Controls from './Controls' import DataGridControls from './DataGridControls' import { getPreviewEligibleRowIndexes, getRowsToProcess, countAIOnlyRows, getAutoMatchAIOnOpen, takeRunRows, clearRunRowStatuses, canRunAlgorithm, spendsMatchQuota, shouldStopAIStep, getAIRequestIdempotencyKey, getCandidatePoolFingerprint, hasCurrentAnalysis, getScispacyRowResults, getPendingRowLookups, waitForLookups, AI_LOOKUP_WAIT_MS, RERANK_LOOKUP_WAIT_MS, RERANK_MAX_IN_FLIGHT, isScispacyWarmingUp } from './autoMatchRows' -import { aiOnByDefault, getMaxRunRows } from '@openconceptlab/oclmap-extensions' +import { aiOnByDefault, getMaxRunRows, getMapperSettings } from '@openconceptlab/oclmap-extensions' import { createAutosaveScheduler, createLatestSender, saveOnLeave, trackSave, whenSaved, installUnloadGuard } from './autosave' import MatchSummaryCard from './MatchSummaryCard' import MappingDecisionResult from './MappingDecisionResult' @@ -628,16 +628,14 @@ const MapProject = () => { const OCL_ONLINE_API_URL = window.OCL_ONLINE_API_URL || process.env.OCL_ONLINE_API_URL const inAIAssistantGroup = Boolean(hasCapability(user, 'users.mapper_ai_assistant') && AI_ASSISTANT_API_URL) const isCoreUser = hasAuthGroup(user, 'core_user') - // Batch size and concurrent requests per algorithm, by the user's tier - // (ocl_online#274, ocl_issues#2849) - const requestLimits = getRequestLimits(user) + // Batch size and concurrent requests per algorithm the user can set + // (ocl_online#274, ocl_issues#2849, ocl_online#517) + const requestLimits = getRequestLimits(getMapperSettings(user)) const mapperPreview = getMapperPreview() // Only staff choose the AI model and prompt template (canSelectAIModel gates - // both pickers). Core, early-access and unlimited-AI users keep the output - // language; preview users get the project's template and its default model. - const { canSelectAIModel, canSetAIOutputLocale } = getAIAssistantChoices( - user, {unlimitedAICalls: mapperPreview.aiAssistantCalls.unlimited} - ) + // both pickers). Users granted it keep the output language; everyone else + // gets the project's template and its default model. + const { canSelectAIModel, canSetAIOutputLocale } = getAIAssistantChoices(user) const CANDIDATES_LIMIT = 15 const canBridge = bridgeRef?.current?.canBridge() const canScispacy = Boolean( diff --git a/src/components/map-projects/__tests__/aiVisibility.test.js b/src/components/map-projects/__tests__/aiVisibility.test.js index db30a115..2e7d5d50 100644 --- a/src/components/map-projects/__tests__/aiVisibility.test.js +++ b/src/components/map-projects/__tests__/aiVisibility.test.js @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } from '../aiVisibility.js' +import { AI_OUTPUT_LOCALE_PERMISSION, canSeeAIInternals, getAIAssistantChoices, getModelUsed, getPromptTemplateKey, createLatestRequestGate } from '../aiVisibility.js' test('canSeeAIInternals: staff and superusers see the model, prompt template, URI and raw JSON', () => { assert.equal(canSeeAIInternals({ is_staff: true }), true) @@ -13,9 +13,8 @@ test('canSeeAIInternals: a non-staff core user does not', () => { assert.equal(canSeeAIInternals({ is_staff: false, is_superuser: false, auth_groups: ['core_user'] }), false) }) -test('canSeeAIInternals: early-access, preview and signed-out users do not', () => { - assert.equal(canSeeAIInternals({ auth_groups: ['early_access'] }), false) - assert.equal(canSeeAIInternals({ auth_groups: ['preview'] }), false) +test('canSeeAIInternals: other and signed-out users do not', () => { + assert.equal(canSeeAIInternals({ auth_groups: ['mapper_ai_assistant'] }), false) assert.equal(canSeeAIInternals(null), false) assert.equal(canSeeAIInternals(undefined), false) }) @@ -26,17 +25,17 @@ test('getAIAssistantChoices: staff and superusers choose the model, the prompt t assert.deepEqual(getAIAssistantChoices({ is_superuser: true }), both) }) -test('getAIAssistantChoices: non-staff core, early-access and unlimited-AI users choose the output language only', () => { +test('getAIAssistantChoices: non-staff users granted the output language choose it only', () => { const localeOnly = { canSelectAIModel: false, canSetAIOutputLocale: true } - assert.deepEqual(getAIAssistantChoices({ is_staff: false, auth_groups: ['core_user'] }), localeOnly) - assert.deepEqual(getAIAssistantChoices({ auth_groups: ['early_access'] }), localeOnly) - assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }, { unlimitedAICalls: true }), localeOnly) + assert.deepEqual(getAIAssistantChoices({ permissions: ['users.mapper_ai_assistant', AI_OUTPUT_LOCALE_PERMISSION] }), localeOnly) + assert.equal(AI_OUTPUT_LOCALE_PERMISSION, 'users.mapper_ai_output_locale') }) -test('getAIAssistantChoices: preview and signed-out users choose nothing', () => { +test('getAIAssistantChoices: other users choose nothing, whatever their groups', () => { const none = { canSelectAIModel: false, canSetAIOutputLocale: false } - assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }), none) - assert.deepEqual(getAIAssistantChoices({ auth_groups: ['preview'] }, { unlimitedAICalls: false }), none) + assert.deepEqual(getAIAssistantChoices({ permissions: ['users.mapper_ai_assistant'] }), none) + assert.deepEqual(getAIAssistantChoices({ auth_groups: ['mapper-approved'], permissions: [] }), none) + assert.deepEqual(getAIAssistantChoices({ auth_groups: ['mapper_ai_output_locale'] }), none) assert.deepEqual(getAIAssistantChoices(null), none) assert.deepEqual(getAIAssistantChoices(undefined), none) }) diff --git a/src/components/map-projects/__tests__/matchBatch.test.js b/src/components/map-projects/__tests__/matchBatch.test.js index 0e8c7635..286d53b2 100644 --- a/src/components/map-projects/__tests__/matchBatch.test.js +++ b/src/components/map-projects/__tests__/matchBatch.test.js @@ -445,6 +445,48 @@ test('runWithConcurrency: at most `concurrency` in flight, every item run once', assert.deepEqual(ran.sort(), [1, 2, 3, 4, 5, 6, 7]) }) +test('runWithConcurrency: runs no more in flight than the server suggests (ocl_online#517)', async () => { + const gate = createCapacityGate() + let inFlight = 0 + let maxInFlight = 0 + const maxAfterSuggestion = [] + const done = await runWithConcurrency([1, 2, 3, 4, 5, 6, 7, 8], { + concurrency: 4, + gate, + run: async item => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + if(item > 4) + maxAfterSuggestion.push(inFlight) + await new Promise(resolve => setTimeout(resolve, 3)) + // the first response suggests 2 + gate.suggest(2) + inFlight -= 1 + }, + }) + assert.equal(done, true) + assert.equal(maxInFlight, 4) + assert.ok(maxAfterSuggestion.every(n => n <= 2), `in flight after the suggestion: ${maxAfterSuggestion}`) +}) + +test('runWithConcurrency: a higher suggestion never raises the project\'s concurrency', async () => { + const gate = createCapacityGate() + gate.suggest(10) + let inFlight = 0 + let maxInFlight = 0 + await runWithConcurrency([1, 2, 3, 4, 5, 6], { + concurrency: 2, + gate, + run: async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise(resolve => setTimeout(resolve, 3)) + inFlight -= 1 + }, + }) + assert.equal(maxInFlight, 2) +}) + test('runWithConcurrency: sends no new batch while the gate is paused, then resumes', async () => { const gate = createCapacityGate() const started = Date.now() diff --git a/src/components/map-projects/__tests__/requestLimits.test.js b/src/components/map-projects/__tests__/requestLimits.test.js index 795ee035..80a68173 100644 --- a/src/components/map-projects/__tests__/requestLimits.test.js +++ b/src/components/map-projects/__tests__/requestLimits.test.js @@ -1,12 +1,10 @@ /** - * Auto Match request limits per algorithm (ocl_online#274). + * Auto Match request settings per algorithm (ocl_online#274, #517). * - * Non-core users can't run batches of more than 10 rows, whether they type it - * in the form or a project saved it earlier. Early-access users keep up to 5 - * requests in flight; preview users 2 (ocl_issues#2849), as many previews at - * once could fill the server's matching capacity. A preview project has at - * most 25 rows (3 batches of 10), so 2 costs one more batch's time. Core users - * and staff keep the form's full range. + * The getMapperSettings checkpoint sets the most rows per request and + * requests in flight a user can choose. The open build allows the form's full + * range, and a project's saved settings run as they are. Below that range, + * saved settings run within it. * * Run with: npm test */ @@ -14,74 +12,52 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { hasFullRequestLimits, getRequestLimits, getRequestSettings, applyRequestSettings } from '../requestLimits.js' +import { getRequestLimits, getRequestSettings, applyRequestSettings } from '../requestLimits.js' +import { getMapperSettings } from '../autoMatchCheck.js' -const FULL = getRequestLimits({ is_staff: true }) -const EARLY_ACCESS = getRequestLimits({ auth_groups: ['early_access'] }) -const PREVIEW = getRequestLimits({ auth_groups: ['preview'] }) +const OPEN = getRequestLimits(getMapperSettings(null)) +const NARROW = getRequestLimits({ batchSize: 10, concurrentRequests: 5 }) -test('hasFullRequestLimits: core users, staff and superusers', () => { - assert.equal(hasFullRequestLimits({ auth_groups: ['core_user'] }), true) - assert.equal(hasFullRequestLimits({ is_staff: true }), true) - assert.equal(hasFullRequestLimits({ is_superuser: true }), true) +test('getRequestLimits: the open build allows the form\'s full range', () => { + assert.deepEqual(OPEN, { batchSize: 1000, concurrentRequests: 50, capped: false }) + assert.deepEqual(getRequestLimits(), OPEN) + assert.deepEqual(getRequestLimits(null), OPEN) }) -test('hasFullRequestLimits: preview, early-access and signed-out users are capped', () => { - assert.equal(hasFullRequestLimits({ auth_groups: ['early_access'] }), false) - assert.equal(hasFullRequestLimits({ auth_groups: [] }), false) - assert.equal(hasFullRequestLimits({ is_staff: false, auth_groups: ['preview'] }), false) - assert.equal(hasFullRequestLimits(null), false) - assert.equal(hasFullRequestLimits(undefined), false) +test('getRequestLimits: settings below the form\'s range cap it', () => { + assert.deepEqual(NARROW, { batchSize: 10, concurrentRequests: 5, capped: true }) + assert.deepEqual(getRequestLimits({ batchSize: 1000, concurrentRequests: 2 }), { batchSize: 1000, concurrentRequests: 2, capped: true }) }) -test('getRequestLimits: the form range for core users and staff', () => { - assert.deepEqual(getRequestLimits({ auth_groups: ['core_user'] }), { batchSize: 1000, concurrentRequests: 50, capped: false }) - assert.deepEqual(FULL, { batchSize: 1000, concurrentRequests: 50, capped: false }) +test('getRequestLimits: settings stay within the form\'s range', () => { + assert.deepEqual(getRequestLimits({ batchSize: 5000, concurrentRequests: 80 }), OPEN) }) -test('getRequestLimits: early access, 10 rows × 5 requests', () => { - assert.deepEqual(EARLY_ACCESS, { batchSize: 10, concurrentRequests: 5, capped: true }) +test('getRequestLimits: missing or invalid settings fall back to the form\'s range', () => { + assert.deepEqual(getRequestLimits({ batchSize: 0, concurrentRequests: 'abc' }), OPEN) + assert.deepEqual(getRequestLimits({ batchSize: '10' }), { batchSize: 10, concurrentRequests: 50, capped: true }) }) -test('getRequestLimits: preview, 10 rows × 2 requests', () => { - assert.deepEqual(PREVIEW, { batchSize: 10, concurrentRequests: 2, capped: true }) -}) - -test('getRequestLimits: a user in no tier, or signed out, gets the preview limits', () => { - assert.deepEqual(getRequestLimits({ auth_groups: [] }), PREVIEW) - assert.deepEqual(getRequestLimits({ auth_groups: ['mapper_ai_assistant'] }), PREVIEW) - assert.deepEqual(getRequestLimits(null), PREVIEW) -}) - -test('getRequestLimits: the highest tier wins', () => { - assert.deepEqual(getRequestLimits({ auth_groups: ['preview', 'early_access'] }), EARLY_ACCESS) - assert.deepEqual(getRequestLimits({ auth_groups: ['early_access', 'core_user'] }), FULL) -}) - -test('getRequestSettings: a capped user\'s saved project runs within the cap', () => { +test('getRequestSettings: a saved project runs within capped settings', () => { // the ticket's case: batch 1 × 25 concurrent semantic requests - assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, EARLY_ACCESS), { batchSize: 1, concurrentRequests: 5 }) - assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, PREVIEW), { batchSize: 1, concurrentRequests: 2 }) - assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 5 }) - assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, PREVIEW), { batchSize: 10, concurrentRequests: 2 }) + assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, NARROW), { batchSize: 1, concurrentRequests: 5 }) + assert.deepEqual(getRequestSettings({ batch_size: 1000, concurrent_requests: 50 }, NARROW), { batchSize: 10, concurrentRequests: 5 }) // ocl-search's shipped default is 50 rows per batch - assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 2 }) + assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, NARROW), { batchSize: 10, concurrentRequests: 2 }) }) -test('getRequestSettings: values within the cap are kept', () => { - assert.deepEqual(getRequestSettings({ batch_size: 10, concurrent_requests: 2 }, EARLY_ACCESS), { batchSize: 10, concurrentRequests: 2 }) - assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 5 }, EARLY_ACCESS), { batchSize: 3, concurrentRequests: 5 }) - assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 1 }, PREVIEW), { batchSize: 3, concurrentRequests: 1 }) +test('getRequestSettings: values within capped settings are kept', () => { + assert.deepEqual(getRequestSettings({ batch_size: 3, concurrent_requests: 5 }, NARROW), { batchSize: 3, concurrentRequests: 5 }) }) -test('getRequestSettings: core users and staff run what the project saved, even past the form\'s range', () => { - assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, FULL), { batchSize: 1, concurrentRequests: 25 }) - assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 2 }, FULL), { batchSize: 50, concurrentRequests: 2 }) - assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }, FULL), { batchSize: 2000, concurrentRequests: 75 }) +test('getRequestSettings: uncapped, a project runs what it saved, even past the form\'s range', () => { + assert.deepEqual(getRequestSettings({ batch_size: 1, concurrent_requests: 25 }, OPEN), { batchSize: 1, concurrentRequests: 25 }) + assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }, OPEN), { batchSize: 2000, concurrentRequests: 75 }) + assert.deepEqual(getRequestSettings({ batch_size: 2000, concurrent_requests: 75 }), { batchSize: 2000, concurrentRequests: 75 }) }) test('getRequestSettings: missing or invalid values fall back to 10 rows × 1 request, as before', () => { - for (const limits of [FULL, EARLY_ACCESS, PREVIEW]) { + for (const limits of [OPEN, NARROW]) { assert.deepEqual(getRequestSettings({}, limits), { batchSize: 10, concurrentRequests: 1 }) assert.deepEqual(getRequestSettings(undefined, limits), { batchSize: 10, concurrentRequests: 1 }) assert.deepEqual(getRequestSettings({ batch_size: 0, concurrent_requests: 0 }, limits), { batchSize: 10, concurrentRequests: 1 }) @@ -89,24 +65,20 @@ test('getRequestSettings: missing or invalid values fall back to 10 rows × 1 re } }) -test('getRequestSettings: without limits, the preview cap applies', () => { - assert.deepEqual(getRequestSettings({ batch_size: 50, concurrent_requests: 25 }), { batchSize: 10, concurrentRequests: 2 }) -}) - test('getRequestSettings: numeric strings are read as numbers', () => { - assert.deepEqual(getRequestSettings({ batch_size: '8', concurrent_requests: '9' }, EARLY_ACCESS), { batchSize: 8, concurrentRequests: 5 }) + assert.deepEqual(getRequestSettings({ batch_size: '8', concurrent_requests: '9' }, NARROW), { batchSize: 8, concurrentRequests: 5 }) }) -test('applyRequestSettings: a capped user\'s algorithm carries the settings the run uses, for the run and its record', () => { +test('applyRequestSettings: capped, the algorithm carries the settings the run uses, for the run and its record', () => { const algo = { id: 'ocl-semantic', type: 'ocl-semantic', batch_size: 1, concurrent_requests: 25, query_params: { semantic: true } } - assert.deepEqual(applyRequestSettings(algo, EARLY_ACCESS), { ...algo, batch_size: 1, concurrent_requests: 5 }) - assert.deepEqual(applyRequestSettings(algo, PREVIEW), { ...algo, batch_size: 1, concurrent_requests: 2 }) - assert.deepEqual(applyRequestSettings({ id: 'ocl-search' }, EARLY_ACCESS), { id: 'ocl-search', batch_size: 10, concurrent_requests: 1 }) + assert.deepEqual(applyRequestSettings(algo, NARROW), { ...algo, batch_size: 1, concurrent_requests: 5 }) + assert.deepEqual(applyRequestSettings({ id: 'ocl-search' }, NARROW), { id: 'ocl-search', batch_size: 10, concurrent_requests: 1 }) // the saved project is not changed assert.equal(algo.concurrent_requests, 25) }) -test('applyRequestSettings: core users and staff get the algorithm back untouched', () => { +test('applyRequestSettings: uncapped, the algorithm comes back untouched', () => { const algo = { id: 'ocl-semantic', batch_size: 2000, concurrent_requests: 75 } - assert.equal(applyRequestSettings(algo, FULL), algo) + assert.equal(applyRequestSettings(algo, OPEN), algo) + assert.equal(applyRequestSettings(algo), algo) }) diff --git a/src/components/map-projects/aiVisibility.js b/src/components/map-projects/aiVisibility.js index 8d082cec..de19dabf 100644 --- a/src/components/map-projects/aiVisibility.js +++ b/src/components/map-projects/aiVisibility.js @@ -1,20 +1,20 @@ // An AI recommendation's internals are staff-only (ocl_online#254): the model, -// the prompt template and its URI, and the raw JSON that carries them. Core, -// early-access and preview users see the assessment and the candidates only. +// the prompt template and its URI, and the raw JSON that carries them. +// Everyone else sees the assessment and the candidates only. export const canSeeAIInternals = user => Boolean(user?.is_staff || user?.is_superuser) -const inAuthGroup = (user, group) => Boolean(user?.auth_groups?.some(name => name.includes(group))) - // What a user can choose for the OCL AI Assistant (ocl_online#259): // - the model and the prompt template: staff only, like seeing them. The AI // Assistant honours a model override from staff only, so everyone else runs // the project's template on that template's default model. -// - the output language: staff, core, early-access and unlimited-AI users. -export const getAIAssistantChoices = (user, { unlimitedAICalls = false } = {}) => ({ +// - the output language: staff, and users an admin has granted it to +// (OpenConceptLab/ocl_online#517). +export const AI_OUTPUT_LOCALE_PERMISSION = 'users.mapper_ai_output_locale' + +export const getAIAssistantChoices = user => ({ canSelectAIModel: canSeeAIInternals(user), canSetAIOutputLocale: Boolean( - canSeeAIInternals(user) || unlimitedAICalls || - inAuthGroup(user, 'core_user') || inAuthGroup(user, 'early_access') + canSeeAIInternals(user) || user?.permissions?.includes(AI_OUTPUT_LOCALE_PERMISSION) ), }) diff --git a/src/components/map-projects/autoMatchCheck.js b/src/components/map-projects/autoMatchCheck.js index 2fbc0433..cd5dee86 100644 --- a/src/components/map-projects/autoMatchCheck.js +++ b/src/components/map-projects/autoMatchCheck.js @@ -1,6 +1,6 @@ -// Checkpoints for AutoMatch (OpenConceptLab/ocl_online#465). The app imports -// them as '@openconceptlab/oclmap-extensions'; webpack resolves that name to an installed -// package when there is one, and to this file otherwise. +// Checkpoints for the Mapper (OpenConceptLab/ocl_online#465, #517). The app +// imports them as '@openconceptlab/oclmap-extensions'; webpack resolves that +// name to an installed package when there is one, and to this file otherwise. // // useAutoMatchCheck receives what the user has selected in the dialog: // {open, autoMatchScope, rowsToMatchCount, totalRows, retrieveCandidates, @@ -20,4 +20,10 @@ export const aiOnByDefault = () => true // eslint-disable-next-line no-unused-vars export const getMaxRunRows = run => null +// The most rows per matching request, and requests in flight per algorithm, +// that the user can set in the form: {batchSize, concurrentRequests}. A saved +// project runs within them too. Here, the form's full range. +// eslint-disable-next-line no-unused-vars +export const getMapperSettings = user => ({batchSize: 1000, concurrentRequests: 50}) + export default useAutoMatchCheck diff --git a/src/components/map-projects/matchBatch.js b/src/components/map-projects/matchBatch.js index 3e0ab6f8..f3ad2a2f 100644 --- a/src/components/map-projects/matchBatch.js +++ b/src/components/map-projects/matchBatch.js @@ -101,9 +101,10 @@ export const runMatchBatch = async ({ /** * The bulk Auto Match scheduler: runs each item through run(item) with at most - * concurrency in flight. While the gate is paused (a request got a 429) it - * sends nothing new, and waits for the gate to reopen or a request in flight - * to settle, so a throttled run doesn't drain its queue into more refusals. + * concurrency in flight, or fewer while the gate's server suggests fewer + * (OpenConceptLab/ocl_online#517). While the gate is paused (a request got a + * 429) it sends nothing new, and waits for the gate to reopen or a request in + * flight to settle, so a throttled run doesn't drain its queue into more refusals. * It holds the queue for at most maxHoldMs in all, however often the pause is * extended; after that, and for a pause longer than what's left of it, the * items go out and wait (or end throttled) as requests of their own. @@ -120,8 +121,9 @@ export const runWithConcurrency = async (items, { const active = new Set() let heldMs = 0 const isHeld = () => Boolean(gate?.isPaused() && gate.pausedForMs() <= maxHoldMs - heldMs) + const getLimit = () => Math.min(concurrency, gate?.suggestedConcurrency?.() || Infinity) while(queue.length || active.size) { - while(queue.length && active.size < concurrency) { + while(queue.length && active.size < getLimit()) { if(shouldAbort()) return false if(shouldSkipRest()) { @@ -138,7 +140,7 @@ export const runWithConcurrency = async (items, { active.add(promise) } const waitingOn = [...active] - const holding = queue.length && active.size < concurrency && isHeld() + const holding = queue.length && active.size < getLimit() && isHeld() if(holding) waitingOn.push(gate.wait(shouldAbort, {pollMs, maxMs: maxHoldMs - heldMs})) if(!waitingOn.length) diff --git a/src/components/map-projects/requestLimits.js b/src/components/map-projects/requestLimits.js index c3ede851..4426ef32 100644 --- a/src/components/map-projects/requestLimits.js +++ b/src/components/map-projects/requestLimits.js @@ -1,47 +1,43 @@ /** * How many rows go in one matching request, and how many requests an - * algorithm keeps in flight, during Auto Match (ocl_online#274). + * algorithm keeps in flight, during Auto Match (ocl_online#274, #517). * - * $match is throttled per minute, not per request in flight, so a run at - * batch 1 × 25 concurrent requests puts 25 semantic searches on the search - * cluster at once. Everyone but core users and staff gets at most 10 rows per - * request, in the form and at run time, where the cap also holds a project's - * earlier saved settings: early-access users up to 5 requests in flight, - * preview users 2 (ocl_issues#2849), since many previews at once could fill - * the server's matching capacity. Core users and staff are unchanged: the - * form's range, and whatever the project saved at run time. + * The most a user can set in the form comes from the getMapperSettings + * checkpoint ('@openconceptlab/oclmap-extensions'); the open build allows the + * form's full range. Below that range, a project's earlier saved settings run + * within it too. At run time the server can lower requests in flight further + * (its suggested concurrency, services/capacity.js). */ export const DEFAULT_BATCH_SIZE = 10 export const DEFAULT_CONCURRENT_REQUESTS = 1 -const FULL_LIMITS = { batchSize: 1000, concurrentRequests: 50, capped: false } -const EARLY_ACCESS_LIMITS = { batchSize: 10, concurrentRequests: 5, capped: true } -const PREVIEW_LIMITS = { batchSize: 10, concurrentRequests: 2, capped: true } - -const inAuthGroup = (user, group) => Boolean(user?.auth_groups?.some(name => name.includes(group))) - -export const hasFullRequestLimits = user => Boolean(user?.is_staff || user?.is_superuser || inAuthGroup(user, 'core_user')) - -// The most rows per request, and requests in flight, a user may type in the -// form, by their highest tier. capped: false for core users and staff, whose -// saved settings run as they are. -export const getRequestLimits = user => { - if(hasFullRequestLimits(user)) - return FULL_LIMITS - if(inAuthGroup(user, 'early_access')) - return EARLY_ACCESS_LIMITS - return PREVIEW_LIMITS -} +const FORM_RANGE = { batchSize: 1000, concurrentRequests: 50 } const toCount = value => { const n = Number.parseInt(value, 10) return n >= 1 ? n : null } +// The form's maximums from the checkpoint's {batchSize, concurrentRequests}, +// within the form's range. capped: true when either is below the range, so the +// form shows its maximum and saved settings run within it; false runs saved +// settings as they are. +export const getRequestLimits = settings => { + const batchSize = Math.min(toCount(settings?.batchSize) || FORM_RANGE.batchSize, FORM_RANGE.batchSize) + const concurrentRequests = Math.min(toCount(settings?.concurrentRequests) || FORM_RANGE.concurrentRequests, FORM_RANGE.concurrentRequests) + return { + batchSize, + concurrentRequests, + capped: batchSize < FORM_RANGE.batchSize || concurrentRequests < FORM_RANGE.concurrentRequests, + } +} + +const OPEN_LIMITS = getRequestLimits() + // The batch size and concurrency an algorithm runs with: its saved values, or // the defaults, within the user's limits (getRequestLimits). -export const getRequestSettings = (algo, limits = PREVIEW_LIMITS) => { +export const getRequestSettings = (algo, limits = OPEN_LIMITS) => { const batchSize = toCount(algo?.batch_size) || DEFAULT_BATCH_SIZE const concurrentRequests = toCount(algo?.concurrent_requests) || DEFAULT_CONCURRENT_REQUESTS if(!limits.capped) @@ -53,8 +49,8 @@ export const getRequestSettings = (algo, limits = PREVIEW_LIMITS) => { } // A copy of a run's algorithm carrying the settings it runs with, so the -// AutomatchRun record matches what ran. Core users and staff get it back as is. -export const applyRequestSettings = (algo, limits = PREVIEW_LIMITS) => { +// AutomatchRun record matches what ran. Uncapped, it comes back as is. +export const applyRequestSettings = (algo, limits = OPEN_LIMITS) => { if(!limits.capped) return algo const { batchSize, concurrentRequests } = getRequestSettings(algo, limits) diff --git a/src/services/__tests__/capacity.test.js b/src/services/__tests__/capacity.test.js index 610647b6..6b0bbb59 100644 --- a/src/services/__tests__/capacity.test.js +++ b/src/services/__tests__/capacity.test.js @@ -322,6 +322,41 @@ test('requestWithCapacityRetry: onWait and onWaitEnd bracket each wait, with the assert.deepEqual(events, [['wait', 'throttled', 10000, 429], ['end'], ['wait', 'error', 2250, 503], ['end']]) }) +test('createCapacityGate: keeps the latest suggested concurrency, at least 1', () => { + const gate = createCapacityGate() + assert.equal(gate.suggestedConcurrency(), null) + gate.suggest(3) + assert.equal(gate.suggestedConcurrency(), 3) + gate.suggest(0) + assert.equal(gate.suggestedConcurrency(), 1) + gate.suggest(NaN) + gate.suggest(undefined) + assert.equal(gate.suggestedConcurrency(), 1) + gate.suggest(5) + assert.equal(gate.suggestedConcurrency(), 5) +}) + +test('requestWithCapacityRetry: the gate keeps the suggested concurrency of each response, without onCapacity', async () => { + const clock = virtualClock() + const gate = createCapacityGate({now: clock.now}) + const {send} = scripted( + throttled(1, {headers: {'retry-after': '1', 'x-ocl-capacity-suggested-concurrency': '1'}}), + ok([], {'x-ocl-capacity-suggested-concurrency': '2'}), + ) + const outcome = await requestWithCapacityRetry(send, {gate, now: clock.now, sleep: clock.sleep, random: () => 0}) + assert.equal(outcome.ok, true) + assert.equal(gate.suggestedConcurrency(), 2) +}) + +test('requestWithCapacityRetry: a response without the header leaves the suggestion as it was', async () => { + const clock = virtualClock() + const gate = createCapacityGate({now: clock.now}) + gate.suggest(4) + const {send} = scripted(ok([])) + await requestWithCapacityRetry(send, {gate, now: clock.now, sleep: clock.sleep}) + assert.equal(gate.suggestedConcurrency(), 4) +}) + test('requestWithCapacityRetry: onCapacity gets the capacity headers of a success and of a 429', async () => { const clock = virtualClock() const seen = [] diff --git a/src/services/capacity.js b/src/services/capacity.js index 7cf5dbde..0130bcdb 100644 --- a/src/services/capacity.js +++ b/src/services/capacity.js @@ -97,8 +97,9 @@ const CAPACITY_HEADERS = { /** * The server's X-OCL-Capacity-* headers (OpenConceptLab/ocl_online#275), or - * null when the response carries none. Read and logged only for now; adapting - * to the suggested concurrency is OpenConceptLab/ocl_online#340. + * null when the response carries none. A gate keeps the latest suggested + * concurrency, and the Auto Match scheduler runs no more requests than it + * (OpenConceptLab/ocl_online#517). */ export const getCapacityHeaders = response => { const capacity = {} @@ -133,13 +134,21 @@ export const sleepUnlessCancelled = async (ms, isCancelled = () => false, { slee /** * One per server, shared by the requests a tab sends it. A 429 on any of them * pauses the gate for its Retry-After, and the others (and the Auto Match - * scheduler) hold back until it reopens. + * scheduler) hold back until it reopens. It also keeps the concurrency the + * server last suggested for this user, at least 1 (a paused lane suggests 0, + * and its 429s pause the gate instead), or null before any suggestion. */ export const createCapacityGate = ({ now = Date.now } = {}) => { let resumeAt = 0 let pausedBy = CAPACITY_LIMIT + let suggestedConcurrency = null const pausedForMs = () => Math.max(resumeAt - now(), 0) return { + suggest: concurrency => { + if(Number.isFinite(concurrency)) + suggestedConcurrency = Math.max(Math.floor(concurrency), 1) + }, + suggestedConcurrency: () => suggestedConcurrency, pause: (ms, limit = CAPACITY_LIMIT) => { const at = now() + ms if(at >= resumeAt) { @@ -208,9 +217,12 @@ export const requestWithCapacityRetry = async (send, { const end = fields => ({ ...fields, attempts, waitedMs }) const cancelled = () => end({ ok: false, reason: 'cancelled', error: lastError }) const reportCapacity = response => { - const capacity = onCapacity ? getCapacityHeaders(response) : null - if(capacity) - onCapacity(capacity) + const capacity = (onCapacity || gate?.suggest) ? getCapacityHeaders(response) : null + if(!capacity) + return + if(capacity.suggestedConcurrency !== undefined) + gate?.suggest?.(capacity.suggestedConcurrency) + onCapacity?.(capacity) } const waitFor = async (delayMs, info) => { onWait?.({ ...info, delayMs }) From d183ebb68a90c4b5b8ed03585e1c90316b0e679b Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Thu, 22 Oct 2026 07:30:34 +0530 Subject: [PATCH 2/2] OpenConceptLab/ocl_online#517 | Leave following the suggested concurrency to #340 --- .../map-projects/__tests__/matchBatch.test.js | 42 ------------------- src/components/map-projects/matchBatch.js | 12 +++--- src/components/map-projects/requestLimits.js | 4 +- src/services/__tests__/capacity.test.js | 35 ---------------- src/services/capacity.js | 24 +++-------- 5 files changed, 13 insertions(+), 104 deletions(-) diff --git a/src/components/map-projects/__tests__/matchBatch.test.js b/src/components/map-projects/__tests__/matchBatch.test.js index 286d53b2..0e8c7635 100644 --- a/src/components/map-projects/__tests__/matchBatch.test.js +++ b/src/components/map-projects/__tests__/matchBatch.test.js @@ -445,48 +445,6 @@ test('runWithConcurrency: at most `concurrency` in flight, every item run once', assert.deepEqual(ran.sort(), [1, 2, 3, 4, 5, 6, 7]) }) -test('runWithConcurrency: runs no more in flight than the server suggests (ocl_online#517)', async () => { - const gate = createCapacityGate() - let inFlight = 0 - let maxInFlight = 0 - const maxAfterSuggestion = [] - const done = await runWithConcurrency([1, 2, 3, 4, 5, 6, 7, 8], { - concurrency: 4, - gate, - run: async item => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - if(item > 4) - maxAfterSuggestion.push(inFlight) - await new Promise(resolve => setTimeout(resolve, 3)) - // the first response suggests 2 - gate.suggest(2) - inFlight -= 1 - }, - }) - assert.equal(done, true) - assert.equal(maxInFlight, 4) - assert.ok(maxAfterSuggestion.every(n => n <= 2), `in flight after the suggestion: ${maxAfterSuggestion}`) -}) - -test('runWithConcurrency: a higher suggestion never raises the project\'s concurrency', async () => { - const gate = createCapacityGate() - gate.suggest(10) - let inFlight = 0 - let maxInFlight = 0 - await runWithConcurrency([1, 2, 3, 4, 5, 6], { - concurrency: 2, - gate, - run: async () => { - inFlight += 1 - maxInFlight = Math.max(maxInFlight, inFlight) - await new Promise(resolve => setTimeout(resolve, 3)) - inFlight -= 1 - }, - }) - assert.equal(maxInFlight, 2) -}) - test('runWithConcurrency: sends no new batch while the gate is paused, then resumes', async () => { const gate = createCapacityGate() const started = Date.now() diff --git a/src/components/map-projects/matchBatch.js b/src/components/map-projects/matchBatch.js index f3ad2a2f..3e0ab6f8 100644 --- a/src/components/map-projects/matchBatch.js +++ b/src/components/map-projects/matchBatch.js @@ -101,10 +101,9 @@ export const runMatchBatch = async ({ /** * The bulk Auto Match scheduler: runs each item through run(item) with at most - * concurrency in flight, or fewer while the gate's server suggests fewer - * (OpenConceptLab/ocl_online#517). While the gate is paused (a request got a - * 429) it sends nothing new, and waits for the gate to reopen or a request in - * flight to settle, so a throttled run doesn't drain its queue into more refusals. + * concurrency in flight. While the gate is paused (a request got a 429) it + * sends nothing new, and waits for the gate to reopen or a request in flight + * to settle, so a throttled run doesn't drain its queue into more refusals. * It holds the queue for at most maxHoldMs in all, however often the pause is * extended; after that, and for a pause longer than what's left of it, the * items go out and wait (or end throttled) as requests of their own. @@ -121,9 +120,8 @@ export const runWithConcurrency = async (items, { const active = new Set() let heldMs = 0 const isHeld = () => Boolean(gate?.isPaused() && gate.pausedForMs() <= maxHoldMs - heldMs) - const getLimit = () => Math.min(concurrency, gate?.suggestedConcurrency?.() || Infinity) while(queue.length || active.size) { - while(queue.length && active.size < getLimit()) { + while(queue.length && active.size < concurrency) { if(shouldAbort()) return false if(shouldSkipRest()) { @@ -140,7 +138,7 @@ export const runWithConcurrency = async (items, { active.add(promise) } const waitingOn = [...active] - const holding = queue.length && active.size < getLimit() && isHeld() + const holding = queue.length && active.size < concurrency && isHeld() if(holding) waitingOn.push(gate.wait(shouldAbort, {pollMs, maxMs: maxHoldMs - heldMs})) if(!waitingOn.length) diff --git a/src/components/map-projects/requestLimits.js b/src/components/map-projects/requestLimits.js index 4426ef32..ab59f849 100644 --- a/src/components/map-projects/requestLimits.js +++ b/src/components/map-projects/requestLimits.js @@ -5,8 +5,8 @@ * The most a user can set in the form comes from the getMapperSettings * checkpoint ('@openconceptlab/oclmap-extensions'); the open build allows the * form's full range. Below that range, a project's earlier saved settings run - * within it too. At run time the server can lower requests in flight further - * (its suggested concurrency, services/capacity.js). + * within it too. Following the server's suggested concurrency is + * OpenConceptLab/ocl_online#340. */ export const DEFAULT_BATCH_SIZE = 10 diff --git a/src/services/__tests__/capacity.test.js b/src/services/__tests__/capacity.test.js index 6b0bbb59..610647b6 100644 --- a/src/services/__tests__/capacity.test.js +++ b/src/services/__tests__/capacity.test.js @@ -322,41 +322,6 @@ test('requestWithCapacityRetry: onWait and onWaitEnd bracket each wait, with the assert.deepEqual(events, [['wait', 'throttled', 10000, 429], ['end'], ['wait', 'error', 2250, 503], ['end']]) }) -test('createCapacityGate: keeps the latest suggested concurrency, at least 1', () => { - const gate = createCapacityGate() - assert.equal(gate.suggestedConcurrency(), null) - gate.suggest(3) - assert.equal(gate.suggestedConcurrency(), 3) - gate.suggest(0) - assert.equal(gate.suggestedConcurrency(), 1) - gate.suggest(NaN) - gate.suggest(undefined) - assert.equal(gate.suggestedConcurrency(), 1) - gate.suggest(5) - assert.equal(gate.suggestedConcurrency(), 5) -}) - -test('requestWithCapacityRetry: the gate keeps the suggested concurrency of each response, without onCapacity', async () => { - const clock = virtualClock() - const gate = createCapacityGate({now: clock.now}) - const {send} = scripted( - throttled(1, {headers: {'retry-after': '1', 'x-ocl-capacity-suggested-concurrency': '1'}}), - ok([], {'x-ocl-capacity-suggested-concurrency': '2'}), - ) - const outcome = await requestWithCapacityRetry(send, {gate, now: clock.now, sleep: clock.sleep, random: () => 0}) - assert.equal(outcome.ok, true) - assert.equal(gate.suggestedConcurrency(), 2) -}) - -test('requestWithCapacityRetry: a response without the header leaves the suggestion as it was', async () => { - const clock = virtualClock() - const gate = createCapacityGate({now: clock.now}) - gate.suggest(4) - const {send} = scripted(ok([])) - await requestWithCapacityRetry(send, {gate, now: clock.now, sleep: clock.sleep}) - assert.equal(gate.suggestedConcurrency(), 4) -}) - test('requestWithCapacityRetry: onCapacity gets the capacity headers of a success and of a 429', async () => { const clock = virtualClock() const seen = [] diff --git a/src/services/capacity.js b/src/services/capacity.js index 0130bcdb..7cf5dbde 100644 --- a/src/services/capacity.js +++ b/src/services/capacity.js @@ -97,9 +97,8 @@ const CAPACITY_HEADERS = { /** * The server's X-OCL-Capacity-* headers (OpenConceptLab/ocl_online#275), or - * null when the response carries none. A gate keeps the latest suggested - * concurrency, and the Auto Match scheduler runs no more requests than it - * (OpenConceptLab/ocl_online#517). + * null when the response carries none. Read and logged only for now; adapting + * to the suggested concurrency is OpenConceptLab/ocl_online#340. */ export const getCapacityHeaders = response => { const capacity = {} @@ -134,21 +133,13 @@ export const sleepUnlessCancelled = async (ms, isCancelled = () => false, { slee /** * One per server, shared by the requests a tab sends it. A 429 on any of them * pauses the gate for its Retry-After, and the others (and the Auto Match - * scheduler) hold back until it reopens. It also keeps the concurrency the - * server last suggested for this user, at least 1 (a paused lane suggests 0, - * and its 429s pause the gate instead), or null before any suggestion. + * scheduler) hold back until it reopens. */ export const createCapacityGate = ({ now = Date.now } = {}) => { let resumeAt = 0 let pausedBy = CAPACITY_LIMIT - let suggestedConcurrency = null const pausedForMs = () => Math.max(resumeAt - now(), 0) return { - suggest: concurrency => { - if(Number.isFinite(concurrency)) - suggestedConcurrency = Math.max(Math.floor(concurrency), 1) - }, - suggestedConcurrency: () => suggestedConcurrency, pause: (ms, limit = CAPACITY_LIMIT) => { const at = now() + ms if(at >= resumeAt) { @@ -217,12 +208,9 @@ export const requestWithCapacityRetry = async (send, { const end = fields => ({ ...fields, attempts, waitedMs }) const cancelled = () => end({ ok: false, reason: 'cancelled', error: lastError }) const reportCapacity = response => { - const capacity = (onCapacity || gate?.suggest) ? getCapacityHeaders(response) : null - if(!capacity) - return - if(capacity.suggestedConcurrency !== undefined) - gate?.suggest?.(capacity.suggestedConcurrency) - onCapacity?.(capacity) + const capacity = onCapacity ? getCapacityHeaders(response) : null + if(capacity) + onCapacity(capacity) } const waitFor = async (delayMs, info) => { onWait?.({ ...info, delayMs })