@opentermsarchive/engine 16.0.2 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/default.json +13 -0
- package/config/test.json +12 -0
- package/package.json +1 -1
- package/scripts/declarations/validate/index.mocha.js +7 -0
- package/scripts/import/index.js +1 -1
- package/scripts/import/loadCommits.js +1 -1
- package/scripts/rewrite/initializer/index.js +1 -1
- package/scripts/rewrite/rewrite-snapshots.js +1 -1
- package/scripts/rewrite/rewrite-versions.js +1 -1
- package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
- package/src/archivist/fetcher/index.test.js +12 -0
- package/src/archivist/index.js +106 -23
- package/src/archivist/index.test.js +659 -9
- package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
- package/src/archivist/recorder/repositories/git/index.js +2 -13
- package/src/archivist/recorder/repositories/git/index.test.js +23 -1
- package/src/archivist/services/index.js +45 -1
- package/src/archivist/services/index.test.js +52 -1
- package/src/archivist/services/sourceDocument.js +8 -2
- package/src/archivist/services/sourceDocument.test.js +37 -0
- package/src/archivist/tracking-results/errors.js +5 -0
- package/src/archivist/tracking-results/index.js +233 -0
- package/src/archivist/tracking-results/index.test.js +406 -0
- package/src/archivist/tracking-results/recorder.js +156 -0
- package/src/archivist/tracking-results/recorder.test.js +559 -0
- package/src/archivist/tracking-results/repository.js +136 -0
- package/src/archivist/tracking-results/repository.test.js +763 -0
- package/src/archivist/tracking-results/run/dataMapper.js +51 -0
- package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
- package/src/archivist/tracking-results/run/index.js +115 -0
- package/src/archivist/tracking-results/run/index.test.js +221 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
- package/src/archivist/tracking-results/terms-result/index.js +42 -0
- package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
- package/src/git/errors.js +1 -0
- package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
- package/src/git/index.test.js +266 -0
- package/src/git/pathSegment.js +18 -0
- package/src/git/pathSegment.test.js +33 -0
- package/src/index.js +5 -4
- package/src/reporter/index.js +9 -4
- package/src/reporter/index.test.js +47 -9
- package/src/archivist/recorder/repositories/git/git.test.js +0 -114
- /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
- /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
package/config/default.json
CHANGED
|
@@ -36,6 +36,19 @@
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
|
+
"tracking-results": {
|
|
40
|
+
"storage": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"git": {
|
|
43
|
+
"path": "./data/tracking-results",
|
|
44
|
+
"publish": false,
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "Open Terms Archive Bot",
|
|
47
|
+
"email": "bot@opentermsarchive.org"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
39
52
|
"fetcher": {
|
|
40
53
|
"waitForElementsTimeout": 10000,
|
|
41
54
|
"navigationTimeout": 30000,
|
package/config/test.json
CHANGED
|
@@ -38,6 +38,18 @@
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
|
+
"tracking-results": {
|
|
42
|
+
"storage": {
|
|
43
|
+
"git": {
|
|
44
|
+
"path": "./test/data/tracking-results",
|
|
45
|
+
"publish": false,
|
|
46
|
+
"author": {
|
|
47
|
+
"name": "Open Terms Archive Testing Bot",
|
|
48
|
+
"email": "bot@opentermsarchive.org"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
41
53
|
"fetcher": {
|
|
42
54
|
"waitForElementsTimeout": 1000
|
|
43
55
|
},
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import * as exposedFilters from '../../../src/archivist/extract/exposedFilters.j
|
|
|
11
11
|
import extract from '../../../src/archivist/extract/index.js';
|
|
12
12
|
import fetch, { launchHeadlessBrowser, stopHeadlessBrowser } from '../../../src/archivist/fetcher/index.js';
|
|
13
13
|
import * as services from '../../../src/archivist/services/index.js';
|
|
14
|
+
import { isPortableFileName } from '../../../src/git/pathSegment.js';
|
|
14
15
|
import DeclarationUtils from '../utils/index.js';
|
|
15
16
|
|
|
16
17
|
import serviceHistorySchema from './service.history.schema.js';
|
|
@@ -65,6 +66,12 @@ export default async options => {
|
|
|
65
66
|
}
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
it('service ID is supported as a cross-platform file name', () => {
|
|
70
|
+
if (!isPortableFileName(serviceId)) {
|
|
71
|
+
throw new Error(`Service ID "${serviceId}" contains unsupported characters: a service ID must be usable as a file name on every platform, so it cannot contain control characters nor \`/ \\ : " < > | * ?\`, nor be "." or "..". See https://docs.opentermsarchive.org/terms/how-to/track-terms/#service-id`);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
68
75
|
it('valid declaration schema', async () => {
|
|
69
76
|
const declaration = JSON.parse(await fs.readFile(filePath));
|
|
70
77
|
|
package/scripts/import/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import mime from 'mime';
|
|
|
8
8
|
import { MongoClient } from 'mongodb';
|
|
9
9
|
import nodeFetch from 'node-fetch';
|
|
10
10
|
|
|
11
|
-
import Git from '../../src/
|
|
11
|
+
import Git from '../../src/git/index.js';
|
|
12
12
|
import * as renamer from '../utils/renamer/index.js';
|
|
13
13
|
|
|
14
14
|
import logger from './logger/index.js';
|
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
import config from 'config';
|
|
6
6
|
import { MongoClient } from 'mongodb';
|
|
7
7
|
|
|
8
|
-
import Git from '../../src/
|
|
8
|
+
import Git from '../../src/git/index.js';
|
|
9
9
|
|
|
10
10
|
import logger from './logger/index.js';
|
|
11
11
|
|
|
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
|
|
5
5
|
import config from 'config';
|
|
6
6
|
|
|
7
|
-
import Git from '../../../src/
|
|
7
|
+
import Git from '../../../src/git/index.js';
|
|
8
8
|
import { fileExists } from '../utils.js';
|
|
9
9
|
|
|
10
10
|
const fs = fsApi.promises;
|
|
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
import config from 'config';
|
|
5
5
|
|
|
6
6
|
import Recorder from '../../src/archivist/recorder/index.js';
|
|
7
|
-
import Git from '../../src/archivist/recorder/repositories/git/git.js';
|
|
8
7
|
import GitRepository from '../../src/archivist/recorder/repositories/git/index.js';
|
|
8
|
+
import Git from '../../src/git/index.js';
|
|
9
9
|
import * as renamer from '../utils/renamer/index.js';
|
|
10
10
|
|
|
11
11
|
import * as initializer from './initializer/index.js';
|
|
@@ -6,9 +6,9 @@ import config from 'config';
|
|
|
6
6
|
import { InaccessibleContentError } from '../../src/archivist/errors.js';
|
|
7
7
|
import extract from '../../src/archivist/extract/index.js';
|
|
8
8
|
import Recorder from '../../src/archivist/recorder/index.js';
|
|
9
|
-
import Git from '../../src/archivist/recorder/repositories/git/git.js';
|
|
10
9
|
import GitRepository from '../../src/archivist/recorder/repositories/git/index.js';
|
|
11
10
|
import * as services from '../../src/archivist/services/index.js';
|
|
11
|
+
import Git from '../../src/git/index.js';
|
|
12
12
|
import * as renamer from '../utils/renamer/index.js';
|
|
13
13
|
|
|
14
14
|
import * as initializer from './initializer/index.js';
|
|
@@ -57,6 +57,14 @@ export default async function fetch(url, config) {
|
|
|
57
57
|
throw new Error(`Network system error ${error.code} occurred when trying to fetch '${url}'`);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
if (error.type == 'max-redirect') { // Node-fetch reports the last redirect target, which may carry per-request tokens and would make the message differ at each attempt
|
|
61
|
+
throw new Error(`maximum redirect reached when trying to fetch '${url}'`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (error.type == 'invalid-redirect') { // Same as above, for the invalid redirect target
|
|
65
|
+
throw new Error(`invalid redirect URL received when trying to fetch '${url}'`);
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
if (error instanceof AbortError) {
|
|
61
69
|
throw new Error(`Timed out after ${config.navigationTimeout / 1000} seconds when trying to fetch '${url}'`);
|
|
62
70
|
}
|
|
@@ -31,6 +31,7 @@ describe('Fetcher', function () {
|
|
|
31
31
|
|
|
32
32
|
before(done => {
|
|
33
33
|
let blockCount = 0;
|
|
34
|
+
let redirectCount = 0;
|
|
34
35
|
|
|
35
36
|
temporaryServer = http.createServer((request, response) => {
|
|
36
37
|
if (request.url === '/') {
|
|
@@ -57,6 +58,9 @@ describe('Fetcher', function () {
|
|
|
57
58
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(termsHTML);
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
if (request.url.startsWith('/redirect-loop')) {
|
|
62
|
+
response.writeHead(302, { Location: `/redirect-loop?token=${redirectCount++}` });
|
|
63
|
+
}
|
|
60
64
|
if (request.url === '/always-block') {
|
|
61
65
|
response.writeHead(403, { 'Content-Type': 'text/html' }).write('<!DOCTYPE html><html><body>Access Denied - Bot Detected</body></html>');
|
|
62
66
|
}
|
|
@@ -226,6 +230,14 @@ describe('Fetcher', function () {
|
|
|
226
230
|
});
|
|
227
231
|
});
|
|
228
232
|
|
|
233
|
+
context('when server redirects endlessly to URLs that differ at each request', () => {
|
|
234
|
+
const redirectLoopUrl = `http://127.0.0.1:${SERVER_PORT}/redirect-loop`;
|
|
235
|
+
|
|
236
|
+
it('throws a FetchDocumentError error that only mentions the requested URL', async () => {
|
|
237
|
+
await expect(fetch({ url: redirectLoopUrl })).to.be.rejectedWith(FetchDocumentError, `maximum redirect reached when trying to fetch '${redirectLoopUrl}'`);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
229
241
|
context('when server is not resolved', () => {
|
|
230
242
|
const notAvailableUrl = 'https://not.available.example';
|
|
231
243
|
|
package/src/archivist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import Snapshot from './recorder/snapshot.js';
|
|
|
11
11
|
import Version from './recorder/version.js';
|
|
12
12
|
import * as services from './services/index.js';
|
|
13
13
|
import Service from './services/service.js';
|
|
14
|
+
import TrackingResults, { MissingCollectionIdError, RUN_ID_TRAILER_KEY } from './tracking-results/index.js';
|
|
14
15
|
|
|
15
16
|
const require = createRequire(import.meta.url);
|
|
16
17
|
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
@@ -43,12 +44,13 @@ export default class Archivist extends events.EventEmitter {
|
|
|
43
44
|
return Object.keys(this.services).sort((a, b) => a.localeCompare(b)); // Sort service IDs by lowercase name to have more intuitive logs;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
constructor({ recorderConfig, fetcherConfig }) {
|
|
47
|
+
constructor({ recorderConfig, fetcherConfig, trackingResultsConfig }) {
|
|
47
48
|
super();
|
|
48
49
|
this.fetcherConfig = fetcherConfig;
|
|
49
50
|
this.recorder = new Recorder(recorderConfig);
|
|
50
51
|
this.fetch = params => fetch({ ...params, config: fetcherConfig });
|
|
51
52
|
this.extract = extract;
|
|
53
|
+
this.trackingResultsConfig = trackingResultsConfig; // Stored for use in initialize, where the tracking-results module is created asynchronously
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
async initialize() {
|
|
@@ -58,13 +60,52 @@ export default class Archivist extends events.EventEmitter {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
await this.recorder.initialize();
|
|
63
|
+
|
|
64
|
+
if (this.trackingResultsConfig) {
|
|
65
|
+
try {
|
|
66
|
+
this.trackingResults = await TrackingResults.create(this.trackingResultsConfig);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (!(error instanceof MissingCollectionIdError)) {
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.emit('warn', { message: `${error.message} Tracking-results is disabled.` }); // An auxiliary audit trail must not prevent tracking itself
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.trackingResults) {
|
|
76
|
+
this.trackingResults.on('warn', (...args) => this.emit('warn', ...args)); // Relay the module's warnings onto the engine's public event surface
|
|
77
|
+
await this.trackingResults.initialize();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
this.initQueue();
|
|
82
|
+
this.declarationsCommit = await this.trackingResults?.getDeclarationsCommit(); // Captured right before loading the declarations, so that it identifies the declarations applied by every run of this process, even if their repository is updated in the meantime
|
|
62
83
|
this.services = await services.load();
|
|
63
84
|
|
|
64
|
-
this.on('error',
|
|
85
|
+
this.on('error', () => this.shutdownOnFatalError());
|
|
86
|
+
|
|
87
|
+
this.emit('info', 'Initialization completed');
|
|
88
|
+
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
initQueue() {
|
|
93
|
+
this.trackingQueue = async.queue(async item => {
|
|
94
|
+
try {
|
|
95
|
+
await this.trackTermsChanges(item);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
await this.handleTrackingError(error, item); // Handled inside the worker so drain() also waits for the failure handling, including the tracking-results write; async.queue's error callback is fire-and-forget and would let completeRun race the recording
|
|
98
|
+
}
|
|
99
|
+
}, MAX_PARALLEL_TRACKING);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fatalShutdownPromise = null;
|
|
103
|
+
|
|
104
|
+
shutdownOnFatalError() {
|
|
105
|
+
this.fatalShutdownPromise ||= (async () => { // Memoised so a second fatal error while the sequence is in flight awaits the same promise, instead of running a concurrent cleanup that would race the finalize pushes and process.exit
|
|
65
106
|
console.log('Abort and clean up operations before exiting…');
|
|
66
107
|
|
|
67
|
-
setTimeout(() => {
|
|
108
|
+
const forceExitTimeout = setTimeout(() => {
|
|
68
109
|
console.log('Cleaning timed out, force process to exit');
|
|
69
110
|
process.exit(2);
|
|
70
111
|
}, 60 * 1000);
|
|
@@ -72,21 +113,27 @@ export default class Archivist extends events.EventEmitter {
|
|
|
72
113
|
this.trackingQueue.kill();
|
|
73
114
|
await stopHeadlessBrowser().then(() => console.log('Headless browser stopped'));
|
|
74
115
|
await this.recorder.finalize().then(() => console.log('Recorder finalized'));
|
|
75
|
-
process.exit(1);
|
|
76
|
-
});
|
|
77
116
|
|
|
78
|
-
|
|
117
|
+
if (this.trackingResults) {
|
|
118
|
+
await this.trackingResults.finalize().then(() => console.log('Tracking-results finalized'));
|
|
119
|
+
}
|
|
79
120
|
|
|
80
|
-
|
|
81
|
-
}
|
|
121
|
+
clearTimeout(forceExitTimeout); // The guard is only needed while the cleanup above may hang; leaving it armed would fire a stray forced exit when process.exit is stubbed in tests
|
|
82
122
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
123
|
+
process.exit(1);
|
|
124
|
+
})();
|
|
125
|
+
|
|
126
|
+
return this.fatalShutdownPromise;
|
|
86
127
|
}
|
|
87
128
|
|
|
88
|
-
handleTrackingError(error, { terms, isRetry }) {
|
|
129
|
+
async handleTrackingError(error, { terms, isRetry, technicalUpgradeOnly }) {
|
|
89
130
|
if (!(error instanceof InaccessibleContentError)) {
|
|
131
|
+
try {
|
|
132
|
+
await this.trackingResults?.recordFailure(terms, [error]); // Recorded before emitting the fatal error: the shutdown sequence finalizes the repositories and nothing may write to them once it started
|
|
133
|
+
} catch (recordError) {
|
|
134
|
+
this.emit('warn', { message: `Could not record the tracking outcome before shutdown: ${recordError.message}`, serviceId: terms.service.id, termsType: terms.type });
|
|
135
|
+
}
|
|
136
|
+
|
|
90
137
|
this.emit('error', {
|
|
91
138
|
message: error.stack,
|
|
92
139
|
serviceId: terms.service.id,
|
|
@@ -105,12 +152,18 @@ export default class Archivist extends events.EventEmitter {
|
|
|
105
152
|
termsType: terms.type,
|
|
106
153
|
});
|
|
107
154
|
|
|
108
|
-
this.trackingQueue.push({ terms, isRetry: true });
|
|
155
|
+
this.trackingQueue.push({ terms, isRetry: true, technicalUpgradeOnly, transientErrors: error.errors }); // Propagate the transient errors across the retry boundary so a successful retry can persist them on the tracking-result
|
|
109
156
|
|
|
110
157
|
return;
|
|
111
158
|
}
|
|
112
159
|
|
|
113
160
|
this.emit('inaccessibleContent', error, terms);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await this.trackingResults?.recordFailure(terms, error.errors);
|
|
164
|
+
} catch (recordError) {
|
|
165
|
+
this.emit('error', { message: recordError.stack, serviceId: terms.service.id, termsType: terms.type });
|
|
166
|
+
}
|
|
114
167
|
}
|
|
115
168
|
|
|
116
169
|
attach(listener) {
|
|
@@ -154,6 +207,10 @@ export default class Archivist extends events.EventEmitter {
|
|
|
154
207
|
|
|
155
208
|
await Promise.all([ launchHeadlessBrowser(this.fetcherConfig.language), this.recorder.initialize() ]);
|
|
156
209
|
|
|
210
|
+
if (!technicalUpgradeOnly) { // Technical upgrades intentionally skip the tracking-results lifecycle: they reprocess existing snapshots and do not represent a substantive tracking attempt. As no run is started for them, the recordings below have no effect
|
|
211
|
+
await this.trackingResults?.startRun({ services: this.services, declarationsCommit: this.declarationsCommit, selectedServicesIds: servicesIds, selectedTermsTypes: termsTypes });
|
|
212
|
+
}
|
|
213
|
+
|
|
157
214
|
this.trackingQueue.concurrency = concurrency;
|
|
158
215
|
|
|
159
216
|
servicesIds.forEach(serviceId => {
|
|
@@ -170,12 +227,22 @@ export default class Archivist extends events.EventEmitter {
|
|
|
170
227
|
await this.trackingQueue.drain();
|
|
171
228
|
}
|
|
172
229
|
|
|
173
|
-
|
|
230
|
+
if (this.trackingResults?.hasRunInProgress) {
|
|
231
|
+
try {
|
|
232
|
+
await this.trackingResults.completeRun();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
this.emit('error', { message: `Failed to complete tracking-results run: ${error.stack}` }); // Fatal: the idempotent shutdown stops the browser, finalizes and pushes both recorders, then exits
|
|
235
|
+
|
|
236
|
+
return this.fatalShutdownPromise; // Prevent the finalizations below from racing the shutdown sequence, and keep the tracking pending until the process exits so that the scheduler does not start a new run in the meantime; run.json stays in_progress and the next boot closes the run additively, as after a crash
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await Promise.all([ stopHeadlessBrowser(), this.recorder.finalize(), this.trackingResults?.finalize() ]);
|
|
174
241
|
|
|
175
242
|
this.emit('trackingCompleted', servicesIds.length, numberOfTerms, technicalUpgradeOnly);
|
|
176
243
|
}
|
|
177
244
|
|
|
178
|
-
async trackTermsChanges({ terms, technicalUpgradeOnly = false }) {
|
|
245
|
+
async trackTermsChanges({ terms, technicalUpgradeOnly = false, transientErrors }) {
|
|
179
246
|
if (!technicalUpgradeOnly) {
|
|
180
247
|
await this.fetchAndRecordSnapshots(terms);
|
|
181
248
|
} else {
|
|
@@ -189,6 +256,12 @@ export default class Archivist extends events.EventEmitter {
|
|
|
189
256
|
}
|
|
190
257
|
|
|
191
258
|
await this.recordVersion(terms, contents.join(Version.SOURCE_DOCUMENTS_SEPARATOR), technicalUpgradeOnly);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await this.trackingResults?.recordSuccess(terms, { transientErrors });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
this.emit('error', { message: `Could not record the tracking-results outcome: ${error.stack}`, serviceId: terms.service.id, termsType: terms.type }); // Emitted here rather than thrown to the tracking error handling, which would record as failed a terms that was successfully tracked, in contradiction with its snapshots and version
|
|
264
|
+
}
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
async fetchAndRecordSnapshots(terms) {
|
|
@@ -258,6 +331,8 @@ export default class Archivist extends events.EventEmitter {
|
|
|
258
331
|
async fetchSourceDocument(sourceDocument) {
|
|
259
332
|
const { location: url, executeClientScripts, cssSelectors } = sourceDocument;
|
|
260
333
|
|
|
334
|
+
sourceDocument.resetObservations(); // A failed fetch must be recorded with the observations of this attempt, not with the previous run's values
|
|
335
|
+
|
|
261
336
|
try {
|
|
262
337
|
const { mimeType, content, fetcher } = await this.fetch({ url, executeClientScripts, cssSelectors });
|
|
263
338
|
|
|
@@ -274,14 +349,13 @@ export default class Archivist extends events.EventEmitter {
|
|
|
274
349
|
}
|
|
275
350
|
|
|
276
351
|
async extractContentsFromSnapshots(terms) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const contents = await Promise.all(terms.sourceDocuments.map(async sourceDocument => {
|
|
352
|
+
// Each callback returns { content } or { error } instead of pushing into a shared array: Promise.all preserves input order while side-effect pushes from concurrent callbacks would order errors by completion time, making event.reasons non-deterministic and triggering spurious "Update failure reasons" tracking-results commits
|
|
353
|
+
const results = await Promise.all(terms.sourceDocuments.map(async sourceDocument => {
|
|
280
354
|
const snapshot = await this.recorder.getLatestSnapshot(terms, sourceDocument.id);
|
|
281
355
|
|
|
282
356
|
try {
|
|
283
357
|
if (!snapshot) { // This can happen if one of the source documents for a terms has not yet been fetched
|
|
284
|
-
return;
|
|
358
|
+
return {};
|
|
285
359
|
}
|
|
286
360
|
|
|
287
361
|
sourceDocument.content = snapshot.content;
|
|
@@ -297,21 +371,29 @@ export default class Archivist extends events.EventEmitter {
|
|
|
297
371
|
|
|
298
372
|
sourceDocument.clearContent(); // Reduce memory usage by clearing no longer needed large content strings
|
|
299
373
|
|
|
300
|
-
return content;
|
|
374
|
+
return { content };
|
|
301
375
|
} catch (error) {
|
|
302
376
|
if (!(error instanceof ExtractDocumentError)) {
|
|
303
377
|
throw error;
|
|
304
378
|
}
|
|
305
379
|
|
|
306
|
-
|
|
380
|
+
return { error };
|
|
307
381
|
}
|
|
308
382
|
}));
|
|
309
383
|
|
|
384
|
+
const extractDocumentErrors = results.map(({ error }) => error).filter(Boolean);
|
|
385
|
+
|
|
310
386
|
if (extractDocumentErrors.length) {
|
|
311
387
|
throw new InaccessibleContentError(extractDocumentErrors);
|
|
312
388
|
}
|
|
313
389
|
|
|
314
|
-
return
|
|
390
|
+
return results.map(({ content }) => content);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
get runIdMetadata() { // Ties snapshots and versions to the tracking-results run that records them; empty outside an active run (module disabled, failed run start, technical upgrades) so the trailer is simply absent
|
|
394
|
+
const runId = this.trackingResults?.currentRunId;
|
|
395
|
+
|
|
396
|
+
return runId ? { [RUN_ID_TRAILER_KEY]: runId } : {};
|
|
315
397
|
}
|
|
316
398
|
|
|
317
399
|
async recordVersion(terms, content, technicalUpgradeOnly) {
|
|
@@ -322,7 +404,7 @@ export default class Archivist extends events.EventEmitter {
|
|
|
322
404
|
termsType: terms.type,
|
|
323
405
|
fetchDate: terms.fetchDate,
|
|
324
406
|
isTechnicalUpgrade: technicalUpgradeOnly,
|
|
325
|
-
metadata: { 'x-engine-version': PACKAGE_VERSION },
|
|
407
|
+
metadata: { 'x-engine-version': PACKAGE_VERSION, ...this.runIdMetadata },
|
|
326
408
|
});
|
|
327
409
|
|
|
328
410
|
await this.recorder.record(record);
|
|
@@ -350,6 +432,7 @@ export default class Archivist extends events.EventEmitter {
|
|
|
350
432
|
'x-engine-version': PACKAGE_VERSION,
|
|
351
433
|
'x-fetcher': sourceDocument.fetcher,
|
|
352
434
|
'x-source-document-location': sourceDocument.location,
|
|
435
|
+
...this.runIdMetadata,
|
|
353
436
|
},
|
|
354
437
|
});
|
|
355
438
|
|