Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions tools/content-node/discover.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ const LOADED = {
const graph =
({ load = [LOADED] } = {}) =>
(cypher, params) => {
// The object's class, and whether it has any relationship, asked first.
if (/AS related/.test(cypher))
return [{ labels: (load[0] ?? LOADED).root.labels, related: load.length > 0 }];
if (/MATCH \(n\)-\[r\]-\(m\)/.test(cypher)) return load;
if (/DBInfo/.test(cypher)) return [{ version: 97 }];
// The edit's author, with no stoichiometry on the relationship: once.
Expand Down Expand Up @@ -258,6 +261,23 @@ describe("/data/discover/{identifier}: Java's errors", () => {
expect([response.status, (await response.json()).messages]).toEqual([422, [null]]);
});

it('never loads what is not found, or not an event: only an event is worth its relationships', async () => {
const loads = () => asked.filter((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher));
for (const [load, status] of [
[[], 404],
[[{ ...LOADED, root: { ...ROOT, labels: ['DatabaseObject', 'Species'] } }], 422],
]) {
answer = graph({ load });
asked.length = 0;
expect((await fetch(`${base}/ContentService/data/discover/48887`)).status).toBe(status);
expect(loads()).toEqual([]);
}
answer = graph();
asked.length = 0;
expect((await fetch(`${base}/ContentService/data/discover/R-HSA-5`)).status).toBe(200);
expect(loads()).toHaveLength(1);
});

it("loads an event with graph-core's findByStId: every relationship, both ways, unsorted", async () => {
answer = graph();
asked.length = 0;
Expand Down
9 changes: 9 additions & 0 deletions tools/content-node/icon.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ describe('the icon endpoints, against a stand-in Solr', () => {
]);
});

it("/icon/{id} found but sent no document is Solr's failure, never an empty 200 to cache", async () => {
answer = found([], 1);
const response = await fetch(`${base}/ContentService/search/icon/R-ICO-013428`);
expect([response.status, (await response.json()).messages]).toEqual([
500,
['Solr exception occurred'],
]);
});

it("/icon/{id} found nowhere is Java's 404, naming the id as given", async () => {
answer = found([]);
const response = await fetch(`${base}/ContentService/search/icon/R-ICO-013428.json`);
Expand Down
5 changes: 4 additions & 1 deletion tools/content-node/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,12 @@ function send(mapping: string, info: ReportInfo, body: ReportTerm | ReportTerm[]
),
signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),
})
.then((response) => {
.then(async (response) => {
if (response.status !== 200)
console.error(`[REP001] The report service answered ${response.status}; no report made`);
// Nothing in the answer is read; unread, it would hold the connection.
// A body already broken has nothing to let go.
await response.body?.cancel().catch(() => {});
})
.catch(() => console.error('[REP002] Report service is unavailable'));
}
18 changes: 16 additions & 2 deletions tools/content-node/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,19 @@ export const endpoints: Endpoint[] = [
const missing = () => notFound(request, `${raw} not found`);
const { where, params } = resolveId(raw);
if (where === 'false') return missing();
// What Java decides from the load below -- found, an event -- asked
// first, alone. The load gathers every relationship of the object, and
// for a species or a database that is hundreds of thousands, to answer
// a 422; only an event is loaded.
const [kind] = await read<{ labels: string[]; related: boolean }>(
`MATCH (n:DatabaseObject) WHERE ${where}
WITH n LIMIT 1
RETURN labels(n) AS labels, EXISTS((n)--()) AS related`,
params
);
if (!kind?.related) return missing();
if (!isA(schemaClassOf(kind.labels), 'Event'))
return javaError(request, 422, 'UNPROCESSABLE_ENTITY', null);
// graph-core's findByStId / findByDbId, as written but for the id's
// test: every relationship, both ways, in the order the store gives.
const [row] = await read<MappedRow>(
Expand Down Expand Up @@ -3511,8 +3524,9 @@ export const endpoints: Endpoint[] = [
status: 200,
type: 'application/json',
// numFound > 0 with no documents cannot happen at Solr's default rows.
// If it did, this throws and fromSolr answers its Solr failure -- not
// Java's NullPointerException answer, as it did before it was typed.
// If it did, searchResult leaves `entries` out, this throws, and
// fromSolr answers its Solr failure -- not Java's NullPointerException
// answer, as it did before it was typed.
body: present(searchResult(answer, false).entries)[0],
};
});
Expand Down
6 changes: 5 additions & 1 deletion tools/content-node/solr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,12 @@ export async function solr(
...(method === 'GET' ? {} : { body: form }),
signal: until,
});
if (!response.ok)
if (!response.ok) {
// Its body unread holds the connection until collected: one request
// that Solr refuses (a query it cannot parse) is enough to ask it.
await response.body?.cancel().catch(() => {});
throw Object.assign(new Error(`Solr answered ${response.status}`), { solr: true });
}
// What the handler sent (JSON's null too); the readers check each part they use.
return (await response.json()) as SolrAnswer | null;
} catch (failure) {
Expand Down
92 changes: 92 additions & 0 deletions tools/content-node/unread-answers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment node
//
// An answer whose body is never read holds its connection until it is
// collected. Solr's refusals (a query it cannot parse) and the report
// service's answers are never read, and either can be asked for by anyone,
// so each is let go at once.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { sendReport } from './report.ts';
import { solr } from './solr.ts';

/** An answer whose body says whether it was let go. */
function answer(status: number) {
const body = { cancelled: false };
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":{"msg":"cannot parse"}}'));
},
cancel() {
body.cancelled = true;
},
});
return { response: new Response(stream, { status }), body };
}

const saved = { ...process.env };
afterEach(() => {
vi.unstubAllGlobals();
process.env = { ...saved };
});

describe('an answer nothing reads', () => {
it("is let go when Solr refuses the query, and the refusal is still Solr's", async () => {
process.env['SOLR_URL'] = 'http://127.0.0.1:1/solr/reactome';
const { response, body } = answer(400);
vi.stubGlobal(
'fetch',
vi.fn(async () => response)
);
await expect(solr('/select', [['q', '(']])).rejects.toMatchObject({
message: 'Solr answered 400',
solr: true,
});
expect(body.cancelled).toBe(true);
});

it("is let go when it is the report service's, whatever its status", async () => {
process.env['REPORT_URL'] = 'http://127.0.0.1:1';
for (const status of [200, 500]) {
const { response, body } = answer(status);
vi.stubGlobal(
'fetch',
vi.fn(async () => response)
);
vi.spyOn(console, 'error').mockImplementation(() => {});
sendReport(
'notfound',
{ agent: null, ip: '203.0.113.1', release: '97' },
{
term: 'XYZ',
resource: '',
}
);
await vi.waitFor(() => expect(body.cancelled).toBe(true));
}
});

it('still says what the report service answered when its body is already broken', async () => {
process.env['REPORT_URL'] = 'http://127.0.0.1:1';
const broken = new ReadableStream({
start(controller) {
controller.error(new Error('terminated'));
},
});
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(broken, { status: 500 }))
);
const logged: string[] = [];
vi.spyOn(console, 'error').mockImplementation((line: string) => logged.push(line));
sendReport(
'notfound',
{ agent: null, ip: '203.0.113.1', release: '97' },
{
term: 'XYZ',
resource: '',
}
);
await vi.waitFor(() => expect(logged).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(logged).toEqual(['[REP001] The report service answered 500; no report made']);
});
});
2 changes: 2 additions & 0 deletions tools/content-node/warm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ describe('main', () => {
const [code, output] = await run(['https://site', '99'], { fetch });
expect(code).toBe(2);
expect(output).toContain('reports release 98, not 99');
// A restart cannot help an edge still holding the old number; a purge can.
expect(output).toContain('purge the edge again');
expect(asked).toEqual(['https://site/ContentService/data/database/version']);
});

Expand Down
3 changes: 2 additions & 1 deletion tools/content-node/warm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ export async function main(
const serving = String(await getJson(`${CS}/data/database/version`));
if (serving !== release) {
log(
`the content API reports release ${serving}, not ${release}: restart it on the new release first`
`the content API reports release ${serving}, not ${release}: restart it on the new release, ` +
'or, if it already is, purge the edge again -- it may hold the old number'
);
return 2;
}
Expand Down
Loading