From b4c99f602c8eb69fd7b80cf55ba2161d3a1b14b4 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 18:14:19 +0000 Subject: [PATCH 1/2] xrefs: identifier and filter as query parameters; a batch's work bounded /references/mapping/{identifier}/xrefs and POST /references/mapping/xrefs wrote the identifier and dbFilter into the Cypher text between double quotes, as ReferenceEntityController does. Both are now parameters. For every identifier and filter without a quote the query and its answer are Java's; one with a quote is looked up as written and matches nothing, where Java answers 500 or runs the query the text has become. The batch endpoint's work is bounded: - it is a deep query (its own share of the pool): each identifier is a scan of every node, and a 10MB body holds millions; - a lookup that throws abandons the rest, as one that answers an error already did -- before, the other workers ran on after the answer; - no lookup starts once the client has gone. A query the database ends (its time limit) is the service's 503 here too, not Java's 500 with Neo4j's message. Each new spec shown failing without its fix. Co-Authored-By: Claude Opus 5.5 --- .../content-node/references-mapping.spec.mjs | 89 ++++++++++++++----- tools/content-node/service.spec.mjs | 8 +- tools/content-node/service.ts | 64 +++++++++---- tools/implicit-any-baseline.json | 2 +- 4 files changed, 117 insertions(+), 46 deletions(-) diff --git a/tools/content-node/references-mapping.spec.mjs b/tools/content-node/references-mapping.spec.mjs index 2c1c0eb4..eb824e3c 100644 --- a/tools/content-node/references-mapping.spec.mjs +++ b/tools/content-node/references-mapping.spec.mjs @@ -3,9 +3,9 @@ // /references/mapping/{identifier}, /references/mapping/{identifier}/xrefs and // POST /references/mapping/xrefs -- the reference entities an identifier // names, and their cross references and physical entities -- with a stand-in -// graph. ReferenceEntityController, which builds its cross-reference query by -// writing the identifier and filter into the text, and graph-core's -// ReferenceEntityRepository. The entities here are invented; Java's answers +// graph. ReferenceEntityController's cross-reference query, with the +// identifier and filter passed as parameters where Java writes them into the +// text, and graph-core's ReferenceEntityRepository. The entities here are invented; Java's answers // were measured 17 Oct 2026. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -44,12 +44,12 @@ const messages = async (response) => [response.status, (await response.json()).m const last = () => asked[asked.length - 1]; describe('crossReferenceQuery', () => { - it("is the controller's text, the identifier and filter written into it as given", () => { - expect(crossReferenceQuery('P1', undefined)).toBe( - 'MATCH (n{identifier:"P1"})<-[:crossReference]-(re:ReferenceEntity)-[:crossReference]->(xf) OPTIONAL MATCH (re)<-[:referenceEntity]-(pe:PhysicalEntity) RETURN re.identifier as reference, [ x in collect(distinct xf) | {identifier:x.identifier, databaseName:x.databaseName, url:x.url}] as crossReferences, collect(distinct pe.stId) as physicalEntities' + it("is the controller's text, the identifier and filter as parameters", () => { + expect(crossReferenceQuery(undefined)).toBe( + 'MATCH (n{identifier:$identifier})<-[:crossReference]-(re:ReferenceEntity)-[:crossReference]->(xf) OPTIONAL MATCH (re)<-[:referenceEntity]-(pe:PhysicalEntity) RETURN re.identifier as reference, [ x in collect(distinct xf) | {identifier:x.identifier, databaseName:x.databaseName, url:x.url}] as crossReferences, collect(distinct pe.stId) as physicalEntities' ); - expect(crossReferenceQuery('P1', 'Ensembl')).toMatch( - /\(xf\) WHERE xf\.databaseName="Ensembl" OPTIONAL MATCH/ + expect(crossReferenceQuery('Ensembl')).toMatch( + /\(xf\) WHERE xf\.databaseName=\$dbFilter OPTIONAL MATCH/ ); }); }); @@ -112,7 +112,7 @@ describe('/references/mapping/{identifier}/xrefs', () => { expect(await response.text()).toBe( '[{"reference":"P1","physicalEntities":["R-HSA-2"],"crossReferences":[{"identifier":"X","databaseName":"D","url":"null"}]}]' ); - expect(last()[0]).toBe(crossReferenceQuery('P1', 'D')); + expect(last()).toEqual([crossReferenceQuery('D'), { identifier: 'P1', dbFilter: 'D' }]); }); it('leaves out what NON_EMPTY leaves out: no entities, an empty text', async () => { @@ -153,7 +153,7 @@ describe('/references/mapping/{identifier}/xrefs', () => { answer = () => []; asked.length = 0; await get('P1/xrefs?dbFilter=A&dbFilter=B'); - expect(last()[0]).toBe(crossReferenceQuery('P1', 'A,B')); + expect(last()).toEqual([crossReferenceQuery('A,B'), { identifier: 'P1', dbFilter: 'A,B' }]); }); it('is a 404 when nothing matches', async () => { @@ -164,24 +164,34 @@ describe('/references/mapping/{identifier}/xrefs', () => { ]); }); - it("is Java's 500 with Neo4j's message when the identifier breaks the query", async () => { + it('looks up an identifier or filter with a quote in it as written, never as query text', async () => { + // Java writes both into the query, so a quote breaks it (a 500) or makes + // it another query; here each is only ever a value, and matches nothing. + answer = () => []; + asked.length = 0; + const response = await get('P36%22897/xrefs?dbFilter=D%22%20OR%20true%20%2F%2F'); + expect(response.status).toBe(404); + expect(asked).toEqual([ + [crossReferenceQuery('D" OR true //'), { identifier: 'P36"897', dbFilter: 'D" OR true //' }], + ]); + expect(asked[0][0]).not.toContain('P36'); + }); + + it("is the service's 503 when the database ends the query, as everywhere", async () => { answer = () => { - throw Object.assign(new Error("Invalid input '8': expected ..."), { - code: 'Neo.ClientError.Statement.SyntaxError', + throw Object.assign(new Error('timed out'), { + code: 'Neo.ClientError.Transaction.TransactionTimedOut', }); }; - expect(await messages(await get('P36%22897/xrefs'))).toEqual([ - 500, - [ - "Invalid input '8': expected ...; Error code 'Neo.ClientError.Statement.SyntaxError'; nested exception is org.neo4j.driver.exceptions.ClientException: Invalid input '8': expected ...", - ], - ]); + const response = await get('P1/xrefs'); + expect(response.status).toBe(503); + expect((await response.json()).reason).toBe('SERVICE_UNAVAILABLE'); }); }); describe('POST /references/mapping/xrefs', () => { it('pages the identifiers, each to its results or NO MATCH', async () => { - answer = (cypher) => (cypher.includes('"P1"') ? [XREF_ROW] : []); + answer = (cypher, params) => (params.identifier === 'P1' ? [XREF_ROW] : []); const response = await post('?page=0&pageSize=2', 'P1, xyz;Q2'); const json = await response.json(); expect(json.content).toEqual([ @@ -199,7 +209,7 @@ describe('POST /references/mapping/xrefs', () => { answer = () => []; asked.length = 0; await post('?dbFilter=A&dbFilter=B', 'P1'); - expect(last()[0]).toBe(crossReferenceQuery('P1', 'A,B')); + expect(last()).toEqual([crossReferenceQuery('A,B'), { identifier: 'P1', dbFilter: 'A,B' }]); }); it('splits on newline and tab too, and trims as String.trim does', async () => { @@ -227,8 +237,8 @@ describe('POST /references/mapping/xrefs', () => { }); it("is the first failure's answer when one identifier breaks the query", async () => { - answer = (cypher) => { - if (cypher.includes('"B"')) + answer = (cypher, params) => { + if (params.identifier === 'B') throw Object.assign(new Error('bad'), { code: 'Neo.ClientError.Statement.SyntaxError' }); return []; }; @@ -236,6 +246,39 @@ describe('POST /references/mapping/xrefs', () => { expect([status, message.startsWith('bad; Error code')]).toEqual([500, true]); }); + it('starts no lookup after one throws', async () => { + asked.length = 0; + // The first lookup fails; the others would each take 5ms. + answer = (cypher, params) => { + if (params.identifier === 'I0') throw new Error('the database is gone'); + return new Promise((resolve) => setTimeout(() => resolve([]), 5)); + }; + const ids = Array.from({ length: 50 }, (_, at) => `I${at}`).join(','); + expect((await post('?pageSize=50', ids)).status).toBe(500); + await new Promise((resolve) => setTimeout(resolve, 300)); + // The four workers' first lookups, and nothing after. + expect(asked.length).toBe(4); + }); + + it('starts no lookup once the client has gone', async () => { + asked.length = 0; + answer = () => new Promise((resolve) => setTimeout(() => resolve([]), 20)); + const ids = Array.from({ length: 100 }, (_, at) => `I${at}`).join(','); + const gone = new AbortController(); + const sent = fetch(`${base}/ContentService/references/mapping/xrefs?pageSize=100`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: ids, + signal: gone.signal, + }).catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 50)); + gone.abort(); + await sent; + await new Promise((resolve) => setTimeout(resolve, 200)); + // Four at a time, 20ms each: about a dozen by the abort, far short of 100. + expect(asked.length).toBeLessThan(40); + }); + it("answers Java's errors: 415, 422, 400 and the paging 500s", async () => { expect(await messages(await post('', 'P1', 'application/json'))).toEqual([ 415, diff --git a/tools/content-node/service.spec.mjs b/tools/content-node/service.spec.mjs index 4b5fd172..6d5b185c 100644 --- a/tools/content-node/service.spec.mjs +++ b/tools/content-node/service.spec.mjs @@ -294,13 +294,15 @@ describe('/pathways/low/entity reads its species as the servlet does', () => { describe('the deep queries, held to their own share of the graph', () => { // The pool grew from 8 to 32 so that a few slow requests cannot hold every // connection; these routes -- a whole tree below an object, or every route - // up to a diagram -- are the ones that can run for the full read timeout, - // so they keep a smaller share of their own. - it('marks the in-depth and locationsInPWB routes as deep', () => { + // up to a diagram, a scan of every node per identifier in a batch -- are + // the ones that can run for the full read timeout, so they keep a smaller + // share of their own. + it('marks the in-depth, locationsInPWB and batch xrefs routes as deep', () => { const deep = endpoints.filter((endpoint) => endpoint.deep).map((endpoint) => endpoint.path); expect(deep).toEqual([ '/ContentService/data/entity/{id}/in-depth', '/ContentService/data/event/{id}/in-depth', + '/ContentService/references/mapping/xrefs', '/ContentService/data/detail/{id}/locationsInPWB', ]); }); diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index a5ad655c..2d61aae3 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -1692,6 +1692,9 @@ export const endpoints: Endpoint[] = [ path: '/ContentService/references/mapping/xrefs', method: 'post', sampleBody: 'P36897, Q5S007', + // A page is one scan of every node per identifier (the query names no + // label), and a 10MB body holds millions: it takes a share of the pool. + deep: true, differs: [/^\/ContentService\/references\/mapping\/xrefs: \.url: /], /** * The cross references of a batch of identifiers, a page at a time: @@ -1739,9 +1742,18 @@ export const endpoints: Endpoint[] = [ // every node (the query names no label), so one at a time a full page // outlasts the proxy. Four at once, answers kept in order. const trimmed = slice.map(javaTrim); - const answers = await inOrderPool(trimmed, 4, (identifier) => - crossReferenceResults(request, identifier, dbFilter) + const gone = new AbortController(); + request.res?.on('close', () => { + if (!request.res?.writableFinished) gone.abort(); + }); + const answers = await inOrderPool( + trimmed, + 4, + (identifier) => crossReferenceResults(request, identifier, dbFilter), + gone.signal ); + // Nobody to answer: whatever is written goes nowhere. + if (gone.signal.aborted) return { status: 500 }; const failed = answers.find((results) => results?.status); if (failed) return failed; const content = trimmed.map((identifier, at) => ({ [identifier]: answers[at] })); @@ -8047,16 +8059,17 @@ async function mappingAnswer(request, kind) { } /** - * ReferenceEntityController's cross-reference query. The identifier and the - * database filter are written into the text between double quotes, not - * passed as parameters -- so one with a quote in it breaks or rewrites the - * query, and Java's 500 is the answer (crossReferenceResults). Node reads with - * read-only sessions, so text written in can only ever read. Kept character - * for character, since Neo4j's message quotes it. + * ReferenceEntityController's cross-reference query, with the identifier and + * the database filter passed as parameters. Java writes them into the text + * between double quotes, so a quote in either breaks the query -- or + * rewrites it into one the caller chose. Here they are only ever values: an + * identifier with a quote in it is looked up as written and matches nothing, + * where Java answers 500 or whatever the rewritten query returns. For every + * identifier without one, the query and its answer are Java's. */ -export function crossReferenceQuery(identifier, dbFilter) { - const filter = dbFilter === undefined ? '' : `WHERE xf.databaseName="${dbFilter}" `; - return `MATCH (n{identifier:"${identifier}"})<-[:crossReference]-(re:ReferenceEntity)-[:crossReference]->(xf) ${filter}OPTIONAL MATCH (re)<-[:referenceEntity]-(pe:PhysicalEntity) RETURN re.identifier as reference, [ x in collect(distinct xf) | {identifier:x.identifier, databaseName:x.databaseName, url:x.url}] as crossReferences, collect(distinct pe.stId) as physicalEntities`; +export function crossReferenceQuery(dbFilter: string | undefined): string { + const filter = dbFilter === undefined ? '' : 'WHERE xf.databaseName=$dbFilter '; + return `MATCH (n{identifier:$identifier})<-[:crossReference]-(re:ReferenceEntity)-[:crossReference]->(xf) ${filter}OPTIONAL MATCH (re)<-[:referenceEntity]-(pe:PhysicalEntity) RETURN re.identifier as reference, [ x in collect(distinct xf) | {identifier:x.identifier, databaseName:x.databaseName, url:x.url}] as crossReferences, collect(distinct pe.stId) as physicalEntities`; } /** CrossReferenceResult.EMPTY: what an identifier that matches nothing is given. */ @@ -8084,9 +8097,10 @@ function javaAsString(value) { * them (every text through Value.asString), written with NON_EMPTY -- no * empty list or "" -- or [NO_CROSS_REFERENCES] when there are none. * - * A query the identifier or filter has broken or rewritten is Java's 500, - * returned here as the answer to give: a Neo4j client error as Spring - * translates it, or the driver's Uncoercible for a value that is not text. + * A query the database ended (its time limit, memory) is the service's 503, + * as everywhere (timedOut). Any other Neo4j client error is Java's 500 as + * Spring translates it, and a value that is not text the driver's + * Uncoercible; both returned here as the answer to give. */ async function crossReferenceResults(request, identifier, dbFilter) { try { @@ -8098,7 +8112,10 @@ async function crossReferenceResults(request, identifier, dbFilter) { databaseName: string | null; url: string | null; }[]; - }>(crossReferenceQuery(identifier, dbFilter)); + }>(crossReferenceQuery(dbFilter), { + identifier, + ...(dbFilter === undefined ? {} : { dbFilter }), + }); if (rows.length === 0) return [NO_CROSS_REFERENCES]; return rows.map((row) => compact({ @@ -8122,6 +8139,8 @@ async function crossReferenceResults(request, identifier, dbFilter) { 'INTERNAL_SERVER_ERROR', `${failure.message}; Error code 'N/A'; nested exception is org.neo4j.driver.exceptions.value.Uncoercible: ${failure.message}` ); + const ended = timedOut(request, failure); + if (ended) return ended; const code = String(failure?.code ?? ''); const message = code.startsWith('Neo.ClientError.') ? neo4jFailureMessage(failure, [code]) @@ -8175,16 +8194,23 @@ export function pageText(page) { /** * Each of `items` through `work`, at most `width` at a time, the answers in * the items' order -- or the first failure in that order, as a sequential - * walk would meet it, the rest abandoned. + * walk would meet it, the rest abandoned. A throw abandons the rest too, as + * does `signal` (the client gone): no lookup starts once nobody will read + * the answer. */ -async function inOrderPool(items, width, work) { +async function inOrderPool(items, width, work, signal?: AbortSignal) { const answers = new Array(items.length); let next = 0; let stopped = false; const worker = async () => { - while (!stopped && next < items.length) { + while (!stopped && !signal?.aborted && next < items.length) { const at = next++; - answers[at] = await work(items[at]); + try { + answers[at] = await work(items[at]); + } catch (caught) { + stopped = true; + throw caught; + } if (answers[at]?.status) stopped = true; } }; diff --git a/tools/implicit-any-baseline.json b/tools/implicit-any-baseline.json index e0a35ea4..0f4583c1 100644 --- a/tools/implicit-any-baseline.json +++ b/tools/implicit-any-baseline.json @@ -5,7 +5,7 @@ "tools/content-node/domain.ts": 131, "tools/content-node/java-hashmap.ts": 26, "tools/content-node/parameters.ts": 25, - "tools/content-node/service.ts": 431, + "tools/content-node/service.ts": 429, "tools/render/pptx.ts": 72, "tools/render/render-core.ts": 18, "tools/render/service.ts": 23 From a79a6610b9ed6a99ec0d5bd841fc3917b626bcfd Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Tue, 20 Oct 2026 18:42:43 +0000 Subject: [PATCH 2/2] Review of #571: batches get their own share; the claim, exactly - POST /references/mapping/xrefs is no longer a deep route: it shared the four deep places with in-depth and locationsInPWB, which the Pathway Browser reads, so a script paging through batches could have turned those into 503s. Batches have a share of their own, two at a time, taken after the request's own checks (so a malformed one is still Java's 415/400/413/422). - The claim says what is exactly true: an identifier or filter with neither a quote nor a backslash gets Java's query and answer (a backslash begins an escape in Java's text). - The quote spec uses a bare quote; it proves the same thing. - The client-gone spec waits for the first lookup and asserts there was one, so it cannot pass by testing nothing. Each new spec shown failing without its fix. Beta: the xrefs captures identical again. Co-Authored-By: Claude Opus 5.5 --- .../content-node/references-mapping.spec.mjs | 43 +++++++++++++----- tools/content-node/service.spec.mjs | 8 ++-- tools/content-node/service.ts | 45 +++++++++++++------ 3 files changed, 65 insertions(+), 31 deletions(-) diff --git a/tools/content-node/references-mapping.spec.mjs b/tools/content-node/references-mapping.spec.mjs index eb824e3c..63f4b645 100644 --- a/tools/content-node/references-mapping.spec.mjs +++ b/tools/content-node/references-mapping.spec.mjs @@ -164,16 +164,14 @@ describe('/references/mapping/{identifier}/xrefs', () => { ]); }); - it('looks up an identifier or filter with a quote in it as written, never as query text', async () => { - // Java writes both into the query, so a quote breaks it (a 500) or makes - // it another query; here each is only ever a value, and matches nothing. + it('looks up an identifier or filter with a quote in it as written, as a value', async () => { + // Java writes both into its query, where a quote ends the string (its + // 500); here each is only ever a parameter, and matches nothing. answer = () => []; asked.length = 0; - const response = await get('P36%22897/xrefs?dbFilter=D%22%20OR%20true%20%2F%2F'); + const response = await get('P36%22897/xrefs?dbFilter=D%22'); expect(response.status).toBe(404); - expect(asked).toEqual([ - [crossReferenceQuery('D" OR true //'), { identifier: 'P36"897', dbFilter: 'D" OR true //' }], - ]); + expect(asked).toEqual([[crossReferenceQuery('D"'), { identifier: 'P36"897', dbFilter: 'D"' }]]); expect(asked[0][0]).not.toContain('P36'); }); @@ -262,7 +260,12 @@ describe('POST /references/mapping/xrefs', () => { it('starts no lookup once the client has gone', async () => { asked.length = 0; - answer = () => new Promise((resolve) => setTimeout(() => resolve([]), 20)); + let started; + const first = new Promise((resolve) => (started = resolve)); + answer = () => { + started(); + return new Promise((resolve) => setTimeout(() => resolve([]), 20)); + }; const ids = Array.from({ length: 100 }, (_, at) => `I${at}`).join(','); const gone = new AbortController(); const sent = fetch(`${base}/ContentService/references/mapping/xrefs?pageSize=100`, { @@ -271,12 +274,28 @@ describe('POST /references/mapping/xrefs', () => { body: ids, signal: gone.signal, }).catch(() => undefined); - await new Promise((resolve) => setTimeout(resolve, 50)); + await first; gone.abort(); await sent; - await new Promise((resolve) => setTimeout(resolve, 200)); - // Four at a time, 20ms each: about a dozen by the abort, far short of 100. - expect(asked.length).toBeLessThan(40); + await new Promise((resolve) => setTimeout(resolve, 300)); + // The lookups under way when the client left, four at a time, and at + // most one more round as the close arrives; never the hundred. + expect(asked.length).toBeGreaterThan(0); + expect(asked.length).toBeLessThanOrEqual(8); + }); + + it('runs two batches at once and answers a third 503, Retry-After', async () => { + let release; + const held = new Promise((resolve) => (release = resolve)); + answer = () => held.then(() => []); + const two = [post('', 'A'), post('', 'B')]; + await new Promise((resolve) => setTimeout(resolve, 50)); + const third = await post('', 'C'); + expect([third.status, third.headers.get('retry-after')]).toEqual([503, '5']); + release(); + expect((await Promise.all(two)).map((response) => response.status)).toEqual([200, 200]); + // Their places given back. + expect((await post('', 'D')).status).toBe(200); }); it("answers Java's errors: 415, 422, 400 and the paging 500s", async () => { diff --git a/tools/content-node/service.spec.mjs b/tools/content-node/service.spec.mjs index 6d5b185c..4b5fd172 100644 --- a/tools/content-node/service.spec.mjs +++ b/tools/content-node/service.spec.mjs @@ -294,15 +294,13 @@ describe('/pathways/low/entity reads its species as the servlet does', () => { describe('the deep queries, held to their own share of the graph', () => { // The pool grew from 8 to 32 so that a few slow requests cannot hold every // connection; these routes -- a whole tree below an object, or every route - // up to a diagram, a scan of every node per identifier in a batch -- are - // the ones that can run for the full read timeout, so they keep a smaller - // share of their own. - it('marks the in-depth, locationsInPWB and batch xrefs routes as deep', () => { + // up to a diagram -- are the ones that can run for the full read timeout, + // so they keep a smaller share of their own. + it('marks the in-depth and locationsInPWB routes as deep', () => { const deep = endpoints.filter((endpoint) => endpoint.deep).map((endpoint) => endpoint.path); expect(deep).toEqual([ '/ContentService/data/entity/{id}/in-depth', '/ContentService/data/event/{id}/in-depth', - '/ContentService/references/mapping/xrefs', '/ContentService/data/detail/{id}/locationsInPWB', ]); }); diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index 2d61aae3..cf757d74 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -1692,9 +1692,6 @@ export const endpoints: Endpoint[] = [ path: '/ContentService/references/mapping/xrefs', method: 'post', sampleBody: 'P36897, Q5S007', - // A page is one scan of every node per identifier (the query names no - // label), and a 10MB body holds millions: it takes a share of the pool. - deep: true, differs: [/^\/ContentService\/references\/mapping\/xrefs: \.url: /], /** * The cross references of a batch of identifiers, a page at a time: @@ -1746,12 +1743,30 @@ export const endpoints: Endpoint[] = [ request.res?.on('close', () => { if (!request.res?.writableFinished) gone.abort(); }); - const answers = await inOrderPool( - trimmed, - 4, - (identifier) => crossReferenceResults(request, identifier, dbFilter), - gone.signal - ); + // A page is one scan of every node per identifier (the query names no + // label), four at a time, and a 10MB body holds millions: batches have + // a share of their own, apart from the deep routes the site reads. + if (!BATCHES.enter()) + return { + ...javaError( + request, + 503, + 'SERVICE_UNAVAILABLE', + 'The service is busy; try again shortly' + ), + headers: { 'Retry-After': '5' }, + }; + let answers; + try { + answers = await inOrderPool( + trimmed, + 4, + (identifier) => crossReferenceResults(request, identifier, dbFilter), + gone.signal + ); + } finally { + BATCHES.leave(); + } // Nobody to answer: whatever is written goes nowhere. if (gone.signal.aborted) return { status: 500 }; const failed = answers.find((results) => results?.status); @@ -8061,11 +8076,10 @@ async function mappingAnswer(request, kind) { /** * ReferenceEntityController's cross-reference query, with the identifier and * the database filter passed as parameters. Java writes them into the text - * between double quotes, so a quote in either breaks the query -- or - * rewrites it into one the caller chose. Here they are only ever values: an - * identifier with a quote in it is looked up as written and matches nothing, - * where Java answers 500 or whatever the rewritten query returns. For every - * identifier without one, the query and its answer are Java's. + * between double quotes, where a quote or a backslash is not itself: a quote + * breaks Java's query (its 500), and a backslash begins an escape. Here they + * are only ever values, looked up as written. For every identifier and + * filter with neither, the query and its answer are Java's. */ export function crossReferenceQuery(dbFilter: string | undefined): string { const filter = dbFilter === undefined ? '' : 'WHERE xf.databaseName=$dbFilter '; @@ -8191,6 +8205,9 @@ export function pageText(page) { return text.replace(/"\\u0000long:(-?\d+)"/, '$1'); } +/** Batches of cross-reference lookups running at once (POST /references/mapping/xrefs). */ +const BATCHES = deepShare(2); + /** * Each of `items` through `work`, at most `width` at a time, the answers in * the items' order -- or the first failure in that order, as a sequential