@opentermsarchive/engine 16.1.1 → 16.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.1.1",
3
+ "version": "16.2.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
package/src/git/index.js CHANGED
@@ -5,6 +5,7 @@ import simpleGit from 'simple-git';
5
5
 
6
6
  import { GitObjectNotFoundError } from './errors.js';
7
7
  import { parseTrailers, formatTrailers } from './trailers.js';
8
+ import { acquireWriterLock, WriterLockHeldError } from './writerLock.js';
8
9
 
9
10
  export { GitObjectNotFoundError } from './errors.js';
10
11
 
@@ -12,6 +13,8 @@ process.env.LC_ALL = 'en_GB'; // Ensure git messages will be in English as some
12
13
 
13
14
  const fs = fsApi.promises;
14
15
 
16
+ const WRITER_LOCK_FILE_NAME = 'ota-writer.lock';
17
+
15
18
  const OBJECT_NOT_FOUND_MESSAGES = /bad object|not a tree|invalid object name|unknown revision|does not exist|exists on disk, but not in/i;
16
19
 
17
20
  export default class Git {
@@ -51,6 +54,7 @@ export default class Git {
51
54
 
52
55
  this.#connect();
53
56
  await this.git.init();
57
+ await this.#acquireWriterLock();
54
58
 
55
59
  const configFile = path.resolve(this.path, '.git', 'config'); // Anchored to an absolute path: git resolves a relative `--file` argument against its own cwd (the repository), not against process.cwd, so a relative repository path would silently point the write at a nonexistent nested location
56
60
 
@@ -69,6 +73,20 @@ export default class Git {
69
73
  .raw([ 'config', '--file', configFile, 'gc.writeCommitGraph', 'false' ]); // Prevent automatic `git gc` from also writing the commit-graph: the engine writes it explicitly (see `writeCommitGraph`/`updateCommitGraph`), and a concurrent gc write races those, which can leave a stale `commit-graph.lock` and make subsequent operations fail
70
74
  }
71
75
 
76
+ async #acquireWriterLock() { // Held until the process exits, so that no other process writes to the repository meanwhile: concurrent writers race the working tree and the index, and the clean up of one discards the uncommitted changes of the other
77
+ const lockFilePath = path.join(this.path, '.git', WRITER_LOCK_FILE_NAME);
78
+
79
+ try {
80
+ await acquireWriterLock(lockFilePath); // Acquired again silently by the same process, as repositories are initialized again at each tracking run
81
+ } catch (error) {
82
+ if (!(error instanceof WriterLockHeldError)) {
83
+ throw error;
84
+ }
85
+
86
+ throw new Error(`Repository ${this.path} is already being written by the process ${error.holderPid}; stop it before starting another writer, such as the tracker or the technical upgrades, or remove ${lockFilePath} if that process is not an Open Terms Archive writer`);
87
+ }
88
+ }
89
+
72
90
  open() {
73
91
  if (!fsApi.existsSync(path.join(this.path, '.git'))) {
74
92
  throw new Error(`Repository ${this.path} does not exist, it has to be created by a writer such as the tracker first`); // Without this check, git would silently walk up to an enclosing repository, such as the collection one
@@ -183,6 +201,7 @@ export default class Git {
183
201
  }
184
202
 
185
203
  async cleanUp() {
204
+ await fs.rm(path.join(this.path, '.git', 'index.lock'), { force: true }); // Remove a leftover index lock from a Git operation that was killed mid-write, which would otherwise make every subsequent reset, add and commit fail. Safe as the writer lock guarantees that no other process writes to the repository, and readers never lock the index
186
205
  await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
187
206
  await this.git.reset('hard');
188
207
 
@@ -1,13 +1,17 @@
1
+ import { spawn } from 'child_process';
1
2
  import fs from 'fs/promises';
2
3
  import os from 'os';
3
4
  import path from 'path';
4
5
  import { fileURLToPath } from 'url';
5
6
 
6
- import { expect } from 'chai';
7
+ import { expect, use } from 'chai';
8
+ import chaiAsPromised from 'chai-as-promised';
7
9
  import config from 'config';
8
10
 
9
11
  import Git, { GitObjectNotFoundError } from './index.js';
10
12
 
13
+ use(chaiAsPromised);
14
+
11
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
16
  const RECORDER_PATH = path.resolve(__dirname, '../../', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
13
17
 
@@ -28,6 +32,51 @@ describe('Git', () => {
28
32
  return subject.initialize();
29
33
  });
30
34
 
35
+ describe('#initialize', () => {
36
+ const AUTHOR = { name: 'Writer lock tester', email: 'writer-lock@example.com' };
37
+ let repositoryPath;
38
+ let lockFilePath;
39
+
40
+ beforeEach(async () => {
41
+ repositoryPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ota-writer-lock-'));
42
+ lockFilePath = path.join(repositoryPath, '.git', 'ota-writer.lock');
43
+ });
44
+
45
+ afterEach(() => fs.rm(repositoryPath, { recursive: true, force: true }));
46
+
47
+ it('locks the repository with the PID of the current process', async () => {
48
+ await new Git({ path: repositoryPath, author: AUTHOR }).initialize();
49
+
50
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
51
+ });
52
+
53
+ it('can be initialized again by the same process', async () => {
54
+ await new Git({ path: repositoryPath, author: AUTHOR }).initialize();
55
+
56
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.fulfilled;
57
+ });
58
+
59
+ context('when another running process holds the lock', () => {
60
+ let otherProcess;
61
+
62
+ beforeEach(async () => {
63
+ otherProcess = spawn(process.execPath, [ '-e', 'setInterval(() => {}, 1000)' ]);
64
+ await fs.mkdir(path.dirname(lockFilePath), { recursive: true });
65
+ await fs.writeFile(lockFilePath, String(otherProcess.pid));
66
+ });
67
+
68
+ afterEach(() => otherProcess.kill());
69
+
70
+ it('rejects with an error naming the holder', async () => {
71
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.rejectedWith(`already being written by the process ${otherProcess.pid}`);
72
+ });
73
+
74
+ it('names the lock file in the error', async () => {
75
+ await expect(new Git({ path: repositoryPath, author: AUTHOR }).initialize()).to.be.rejectedWith(lockFilePath);
76
+ });
77
+ });
78
+ });
79
+
31
80
  describe('#commit', () => {
32
81
  const expectedFilePath = `${RECORDER_PATH}/test.md`;
33
82
 
@@ -136,6 +185,36 @@ describe('Git', () => {
136
185
  expect(infoDirectoryContent).to.not.include('commit-graph.lock');
137
186
  });
138
187
  });
188
+ context('when an index lock has been left behind by an interrupted process', () => {
189
+ const indexLockFilePath = path.join(RECORDER_PATH, '.git', 'index.lock');
190
+
191
+ before(async () => {
192
+ const filePath = `${RECORDER_PATH}/file-to-clean.md`;
193
+
194
+ await fs.writeFile(filePath, DEFAULT_CONTENT);
195
+ await subject.add(filePath);
196
+ await subject.commit({ filePath, message: DEFAULT_COMMIT_MESSAGE });
197
+
198
+ await fs.writeFile(indexLockFilePath, '');
199
+
200
+ await subject.cleanUp();
201
+ });
202
+
203
+ after(() => subject.destroyHistory());
204
+
205
+ it('removes the stale index lock', async () => {
206
+ await expect(fs.access(indexLockFilePath)).to.be.rejected;
207
+ });
208
+
209
+ it('leaves the repository writable', async () => {
210
+ const filePath = `${RECORDER_PATH}/file-after-clean.md`;
211
+
212
+ await fs.writeFile(filePath, DEFAULT_CONTENT);
213
+ await subject.add(filePath);
214
+
215
+ expect(await subject.commit({ filePath, message: DEFAULT_COMMIT_MESSAGE })).to.be.a('string');
216
+ });
217
+ });
139
218
  });
140
219
 
141
220
  describe('.getHeadSha', () => {
@@ -0,0 +1,101 @@
1
+ import fsApi from 'fs';
2
+ import os from 'os';
3
+
4
+ const fs = fsApi.promises;
5
+
6
+ const heldLocks = new Set();
7
+
8
+ process.on('exit', () => heldLocks.forEach(release)); // A process killed without exiting leaves its lock behind, which the next taker detects as stale
9
+
10
+ export class WriterLockHeldError extends Error {
11
+ constructor(lockFilePath, holderPid) {
12
+ super(`Lock ${lockFilePath} is held by the process ${holderPid}`);
13
+ this.name = 'WriterLockHeldError';
14
+ this.lockFilePath = lockFilePath;
15
+ this.holderPid = holderPid;
16
+ }
17
+ }
18
+
19
+ export async function acquireWriterLock(lockFilePath) { // Held until the process exits; a process already holding the lock acquires it again silently
20
+ try {
21
+ await fs.writeFile(lockFilePath, String(process.pid), { flag: 'wx' }); // Exclusive creation, so that only one of two processes starting at the same time gets the lock
22
+ } catch (error) {
23
+ if (error.code !== 'EEXIST') {
24
+ throw error;
25
+ }
26
+
27
+ const { holderPid, writtenAt } = await read(lockFilePath);
28
+
29
+ if (holderPid === process.pid) { // Left by a previous process that had the same PID, such as the same container restarted after a kill
30
+ heldLocks.add(lockFilePath);
31
+
32
+ return;
33
+ }
34
+
35
+ if (isAlive(holderPid) && writtenAt > Date.now() - os.uptime() * 1000) { // A lock written before the last boot is stale, even if its PID has since been reused by another process
36
+ throw new WriterLockHeldError(lockFilePath, holderPid);
37
+ }
38
+
39
+ await takeOver(lockFilePath);
40
+
41
+ return acquireWriterLock(lockFilePath);
42
+ }
43
+
44
+ heldLocks.add(lockFilePath);
45
+ }
46
+
47
+ async function read(lockFilePath) {
48
+ try {
49
+ const [ content, { mtimeMs }] = await Promise.all([ fs.readFile(lockFilePath, 'utf8'), fs.stat(lockFilePath) ]);
50
+
51
+ return { holderPid: Number(content), writtenAt: mtimeMs };
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT') {
54
+ throw error;
55
+ }
56
+
57
+ return { holderPid: null, writtenAt: 0 }; // The holder released the lock in the meantime, so it is taken over as a stale one
58
+ }
59
+ }
60
+
61
+ async function takeOver(lockFilePath) {
62
+ const staleLockFilePath = `${lockFilePath}.${process.pid}`;
63
+
64
+ try {
65
+ await fs.rename(lockFilePath, staleLockFilePath); // Atomic, so that only one of two processes taking over the same stale lock at the same time succeeds; the other finds the fresh lock of the winner when it retries
66
+ } catch (error) {
67
+ if (error.code !== 'ENOENT') {
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ await fs.rm(staleLockFilePath, { force: true });
73
+ }
74
+
75
+ function release(lockFilePath) { // Synchronous, as asynchronous work is not run on exit
76
+ let holder;
77
+
78
+ try {
79
+ holder = fsApi.readFileSync(lockFilePath, 'utf8');
80
+ } catch {
81
+ return; // Already released, for example along with the directory that held it
82
+ }
83
+
84
+ if (holder === String(process.pid)) { // Leave the lock to a process that took it over, which happens when the clock was set forward after this process wrote it
85
+ fsApi.rmSync(lockFilePath, { force: true });
86
+ }
87
+ }
88
+
89
+ function isAlive(pid) {
90
+ if (!Number.isInteger(pid) || pid <= 0) { // An empty or corrupted lock file, left by a process killed while creating it
91
+ return false;
92
+ }
93
+
94
+ try {
95
+ process.kill(pid, 0); // Signal 0 only checks that the process exists
96
+
97
+ return true;
98
+ } catch (error) {
99
+ return error.code === 'EPERM'; // The process exists but belongs to another user
100
+ }
101
+ }
@@ -0,0 +1,148 @@
1
+ import { execFile, spawn } from 'child_process';
2
+ import fs from 'fs/promises';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { fileURLToPath, pathToFileURL } from 'url';
6
+ import { promisify } from 'util';
7
+
8
+ import { expect, use } from 'chai';
9
+ import chaiAsPromised from 'chai-as-promised';
10
+
11
+ import { acquireWriterLock, WriterLockHeldError } from './writerLock.js';
12
+
13
+ use(chaiAsPromised);
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ describe('WriterLock', () => {
18
+ describe('#acquireWriterLock', () => {
19
+ let directory;
20
+ let lockFilePath;
21
+
22
+ beforeEach(async () => {
23
+ directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ota-writer-lock-'));
24
+ lockFilePath = path.join(directory, 'writer.lock');
25
+ });
26
+
27
+ afterEach(() => fs.rm(directory, { recursive: true, force: true }));
28
+
29
+ async function writeLock(content, { date } = {}) {
30
+ await fs.writeFile(lockFilePath, content);
31
+
32
+ if (date) {
33
+ await fs.utimes(lockFilePath, date, date);
34
+ }
35
+ }
36
+
37
+ it('writes the PID of the current process in the lock file', async () => {
38
+ await acquireWriterLock(lockFilePath);
39
+
40
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
41
+ });
42
+
43
+ it('can be acquired again by the same process', async () => {
44
+ await acquireWriterLock(lockFilePath);
45
+
46
+ await expect(acquireWriterLock(lockFilePath)).to.be.fulfilled;
47
+ });
48
+
49
+ context('when another running process holds the lock', () => {
50
+ let otherProcess;
51
+
52
+ beforeEach(async () => {
53
+ otherProcess = spawn(process.execPath, [ '-e', 'setInterval(() => {}, 1000)' ]);
54
+ await writeLock(String(otherProcess.pid));
55
+ });
56
+
57
+ afterEach(() => otherProcess.kill());
58
+
59
+ it('rejects with an error naming the holder and the lock file', async () => {
60
+ const error = await acquireWriterLock(lockFilePath).catch(error => error);
61
+
62
+ expect(error).to.be.an.instanceOf(WriterLockHeldError);
63
+ expect(error.holderPid).to.equal(otherProcess.pid);
64
+ expect(error.lockFilePath).to.equal(lockFilePath);
65
+ });
66
+
67
+ it('leaves the lock to its holder', async () => {
68
+ await acquireWriterLock(lockFilePath).catch(() => {});
69
+
70
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(otherProcess.pid));
71
+ });
72
+
73
+ context('when the lock was written before the last boot', () => {
74
+ beforeEach(() => writeLock(String(otherProcess.pid), { date: new Date(Date.now() - (os.uptime() + 60) * 1000) }));
75
+
76
+ it('takes over the lock', async () => {
77
+ await acquireWriterLock(lockFilePath);
78
+
79
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
80
+ });
81
+ });
82
+ });
83
+
84
+ context('when the lock was left by a process that no longer runs', () => {
85
+ beforeEach(async () => {
86
+ const { pid } = await new Promise(resolve => {
87
+ const exitedProcess = spawn(process.execPath, [ '-e', '' ]);
88
+
89
+ exitedProcess.on('exit', () => resolve(exitedProcess));
90
+ });
91
+
92
+ await writeLock(String(pid));
93
+ });
94
+
95
+ it('takes over the lock', async () => {
96
+ await acquireWriterLock(lockFilePath);
97
+
98
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
99
+ });
100
+
101
+ it('leaves no copy of the stale lock behind', async () => {
102
+ await acquireWriterLock(lockFilePath);
103
+
104
+ expect(await fs.readdir(directory)).to.deep.equal(['writer.lock']);
105
+ });
106
+ });
107
+
108
+ context('when the lock file is empty', () => {
109
+ beforeEach(() => writeLock(''));
110
+
111
+ it('takes over the lock', async () => {
112
+ await acquireWriterLock(lockFilePath);
113
+
114
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
115
+ });
116
+ });
117
+
118
+ context('when the process exits', function () {
119
+ this.timeout(10000); // Starts a separate Node.js process
120
+
121
+ function runInSeparateProcess(script) {
122
+ return promisify(execFile)(process.execPath, [ '--input-type=module', '-e', `import { acquireWriterLock } from ${JSON.stringify(pathToFileURL(path.join(__dirname, 'writerLock.js')).href)}; ${script}` ]); // A file URL rather than a path: on Windows, an absolute path is parsed by the ESM loader as a URL with the drive letter as scheme, which it refuses
123
+ }
124
+
125
+ it('releases the lock', async () => {
126
+ await runInSeparateProcess(`await acquireWriterLock(${JSON.stringify(lockFilePath)});`);
127
+
128
+ await expect(fs.access(lockFilePath)).to.be.rejected;
129
+ });
130
+
131
+ context('when the lock was left by a previous process with the same PID', () => {
132
+ it('releases the lock', async () => {
133
+ await runInSeparateProcess(`import fs from 'fs/promises'; await fs.writeFile(${JSON.stringify(lockFilePath)}, String(process.pid)); await acquireWriterLock(${JSON.stringify(lockFilePath)});`);
134
+
135
+ await expect(fs.access(lockFilePath)).to.be.rejected;
136
+ });
137
+ });
138
+
139
+ context('when another process took the lock over meanwhile', () => {
140
+ it('leaves the lock to that process', async () => {
141
+ await runInSeparateProcess(`import fs from 'fs/promises'; await acquireWriterLock(${JSON.stringify(lockFilePath)}); await fs.writeFile(${JSON.stringify(lockFilePath)}, '${process.pid}');`);
142
+
143
+ expect(await fs.readFile(lockFilePath, 'utf8')).to.equal(String(process.pid));
144
+ });
145
+ });
146
+ });
147
+ });
148
+ });
package/src/index.js CHANGED
@@ -85,7 +85,7 @@ export default async function track({ services, types, schedule }) {
85
85
  }
86
86
 
87
87
  export async function applyTechnicalUpgrades({ services, types }) {
88
- const { archivist, services: filteredServices } = await initialize(services); // Without tracking-results: technical upgrades never record any, and initializing the module would finalize as crashed the run that a tracking process may have in progress
88
+ const { archivist, services: filteredServices } = await initialize(services); // Without tracking-results, as technical upgrades never record any
89
89
 
90
90
  await archivist.applyTechnicalUpgrades({ services: filteredServices, types });
91
91
  }