Skip to content

Commit b50ec5a

Browse files
kriszypHarperfast
authored andcommitted
Merge pull request #493 from HarperFast/fix/clone-config-atomic-write
fix: write Harper config files atomically
1 parent 865ff32 commit b50ec5a

4 files changed

Lines changed: 105 additions & 9 deletions

File tree

config/configUtils.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ function getConfigPath(param) {
8888
if (!rootPath) return value;
8989
return path.resolve(rootPath, value);
9090
}
91+
92+
// Write atomically via temp file + rename so readers don't observe a truncated/empty file
93+
function atomicWriteFile(filePath, content) {
94+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
95+
fs.writeFileSync(tempPath, content);
96+
fs.renameSync(tempPath, filePath);
97+
}
98+
9199
/**
92100
* Builds the Harper config file using user inputs and default values from defaultConfig.yaml
93101
* @param args - any args that the user provided.
@@ -164,7 +172,7 @@ function createConfigFile(args, skipFsValidation = false) {
164172
true
165173
);
166174
}
167-
fs.writeFileSync(configFilePath, String(configDoc));
175+
atomicWriteFile(configFilePath, String(configDoc));
168176
logger.trace(`Config file written to ${configFilePath}`);
169177
}
170178

@@ -389,7 +397,7 @@ function checkForUpdatedConfig(configDoc, configFilePath) {
389397
HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR
390398
);
391399
}
392-
fs.writeFileSync(configFilePath, String(configDoc));
400+
atomicWriteFile(configFilePath, String(configDoc));
393401
}
394402
}
395403

@@ -633,7 +641,7 @@ function updateConfigValue(
633641
HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR
634642
);
635643
}
636-
fs.writeFileSync(configFileLocation, String(configDoc));
644+
atomicWriteFile(configFileLocation, String(configDoc));
637645
if (update_config_obj) {
638646
flatConfigObj = flattenConfig(configDoc.toJSON());
639647
}
@@ -895,7 +903,7 @@ function applyRuntimeEnvVarConfig(configDoc, configFilePath, options = {}) {
895903
HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR
896904
);
897905
}
898-
fs.writeFileSync(configFilePath, String(configDoc));
906+
atomicWriteFile(configFilePath, String(configDoc));
899907
logger.debug('Config file updated with runtime env var values');
900908
} catch (error) {
901909
logger.error(`Failed to write config file after applying runtime env vars: ${error.message}`);
@@ -956,7 +964,7 @@ async function addConfig(topLevelElement, values) {
956964
HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR
957965
);
958966
}
959-
await fs.writeFile(getConfigFilePath(), String(configDoc));
967+
atomicWriteFile(getConfigFilePath(), String(configDoc));
960968
}
961969

962970
function deleteConfigFromFile(param) {
@@ -965,7 +973,7 @@ function deleteConfigFromFile(param) {
965973
configDoc.deleteIn(param);
966974
const hdbRoot = configDoc.getIn(['rootPath']);
967975
const configFileLocation = path.join(hdbRoot, hdbTerms.HARPER_CONFIG_FILE);
968-
fs.writeFileSync(configFileLocation, String(configDoc));
976+
atomicWriteFile(configFileLocation, String(configDoc));
969977
}
970978

971979
function getConfigObj() {

unitTests/config/configUtils-runtimeEnvVars.test.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ describe('configUtils - applyRuntimeEnvVarConfig', function () {
1111
let mockConfigDoc;
1212
let applyRuntimeEnvConfigStub;
1313
let fsWriteFileSyncStub;
14+
let fsRenameSyncStub;
1415
let loggerStub;
1516
let YAMLStub;
1617

1718
before(function () {
1819
// Create stubs for dependencies
1920
applyRuntimeEnvConfigStub = sinon.stub();
2021
fsWriteFileSyncStub = sinon.stub();
22+
fsRenameSyncStub = sinon.stub();
2123
loggerStub = {
2224
debug: sinon.stub(),
2325
warn: sinon.stub(),
@@ -32,7 +34,7 @@ describe('configUtils - applyRuntimeEnvVarConfig', function () {
3234

3335
// Inject stubs
3436
configUtils.__set__('logger', loggerStub);
35-
configUtils.__set__('fs', { writeFileSync: fsWriteFileSyncStub });
37+
configUtils.__set__('fs', { writeFileSync: fsWriteFileSyncStub, renameSync: fsRenameSyncStub });
3638
configUtils.__set__('YAML', YAMLStub);
3739

3840
// Mock harperConfigEnvVars module
@@ -48,6 +50,7 @@ describe('configUtils - applyRuntimeEnvVarConfig', function () {
4850
// Reset stubs
4951
applyRuntimeEnvConfigStub.reset();
5052
fsWriteFileSyncStub.reset();
53+
fsRenameSyncStub.reset();
5154
loggerStub.debug.reset();
5255
loggerStub.warn.reset();
5356
loggerStub.error.reset();
@@ -140,8 +143,13 @@ describe('configUtils - applyRuntimeEnvVarConfig', function () {
140143

141144
applyRuntimeEnvVarConfig(mockConfigDoc, '/test/config.yaml');
142145

146+
// Atomic write: writeFileSync writes to a temp path, then renameSync moves it over the target
143147
assert.strictEqual(fsWriteFileSyncStub.called, true);
144-
assert.strictEqual(fsWriteFileSyncStub.firstCall.args[0], '/test/config.yaml');
148+
const writeTarget = fsWriteFileSyncStub.firstCall.args[0];
149+
assert.ok(writeTarget.startsWith('/test/config.yaml.'), `expected temp path, got ${writeTarget}`);
150+
assert.ok(writeTarget.endsWith('.tmp'), `expected .tmp suffix, got ${writeTarget}`);
151+
assert.strictEqual(fsRenameSyncStub.calledOnce, true);
152+
assert.deepStrictEqual(fsRenameSyncStub.firstCall.args, [writeTarget, '/test/config.yaml']);
145153
assert.strictEqual(loggerStub.debug.called, true);
146154
assert.match(loggerStub.debug.firstCall.args[0], /Config file updated/);
147155

unitTests/config/configUtils.test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,67 @@ describe('Test configUtils module', () => {
136136
});
137137
});
138138

139+
describe('Test atomicWriteFile function', () => {
140+
const atomicWriteFile = config_utils_rw.__get__('atomicWriteFile');
141+
const ATOMIC_TEST_DIR = path.join(DIRNAME, 'yaml');
142+
const ATOMIC_TEST_PATH = path.join(ATOMIC_TEST_DIR, 'atomic-write-test.yaml');
143+
144+
before(() => {
145+
fs.ensureDirSync(ATOMIC_TEST_DIR);
146+
});
147+
148+
afterEach(() => {
149+
try {
150+
fs.unlinkSync(ATOMIC_TEST_PATH);
151+
} catch {}
152+
// Clean up any stray temp files from a failed run
153+
for (const entry of fs.readdirSync(ATOMIC_TEST_DIR)) {
154+
if (entry.startsWith('atomic-write-test.yaml.') && entry.endsWith('.tmp')) {
155+
fs.unlinkSync(path.join(ATOMIC_TEST_DIR, entry));
156+
}
157+
}
158+
});
159+
160+
it('writes content to the target path', () => {
161+
atomicWriteFile(ATOMIC_TEST_PATH, 'rootPath: /tmp/hdb');
162+
expect(fs.readFileSync(ATOMIC_TEST_PATH, 'utf8')).to.equal('rootPath: /tmp/hdb');
163+
});
164+
165+
it('overwrites an existing file', () => {
166+
fs.writeFileSync(ATOMIC_TEST_PATH, 'rootPath: /old');
167+
atomicWriteFile(ATOMIC_TEST_PATH, 'rootPath: /new');
168+
expect(fs.readFileSync(ATOMIC_TEST_PATH, 'utf8')).to.equal('rootPath: /new');
169+
});
170+
171+
it('writes via temp file then rename so target is never truncated mid-write', () => {
172+
const writeStub = sandbox.stub(fs, 'writeFileSync');
173+
const renameStub = sandbox.stub(fs, 'renameSync');
174+
try {
175+
atomicWriteFile(ATOMIC_TEST_PATH, 'content');
176+
expect(writeStub.calledOnce).to.be.true;
177+
expect(renameStub.calledOnce).to.be.true;
178+
const tempPath = writeStub.firstCall.args[0];
179+
expect(tempPath).to.not.equal(ATOMIC_TEST_PATH);
180+
expect(tempPath.startsWith(`${ATOMIC_TEST_PATH}.`)).to.be.true;
181+
expect(tempPath.endsWith('.tmp')).to.be.true;
182+
expect(path.dirname(tempPath)).to.equal(path.dirname(ATOMIC_TEST_PATH));
183+
expect(renameStub.firstCall.args).to.deep.equal([tempPath, ATOMIC_TEST_PATH]);
184+
expect(writeStub.calledBefore(renameStub)).to.be.true;
185+
} finally {
186+
writeStub.restore();
187+
renameStub.restore();
188+
}
189+
});
190+
191+
it('leaves no temp file behind after a successful write', () => {
192+
atomicWriteFile(ATOMIC_TEST_PATH, 'content');
193+
const stragglers = fs
194+
.readdirSync(path.dirname(ATOMIC_TEST_PATH))
195+
.filter((e) => e.startsWith('atomic-write-test.yaml.') && e.endsWith('.tmp'));
196+
expect(stragglers).to.be.empty;
197+
});
198+
});
199+
139200
describe('Test getDefaultConfig function', () => {
140201
const expected_flat_default_config_obj = {
141202
analytics_aggregateperiod: 60,

unitTests/config/rootConfigWatcher.test.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const { RootConfigWatcher } = require('#src/config/RootConfigWatcher');
33
const { tmpdir } = require('node:os');
44
const { once } = require('node:events');
55
const { join } = require('node:path');
6-
const { writeFileSync, mkdtempSync, rmSync } = require('node:fs');
6+
const { writeFileSync, mkdtempSync, rmSync, renameSync } = require('node:fs');
77
const { writeFile } = require('node:fs/promises');
88
const { replace, fake, restore, spy } = require('sinon');
99
const configUtils = require('#js/config/configUtils');
@@ -56,4 +56,23 @@ describe('RootConfigWatcher', () => {
5656
'RootConfigWatcher should not have a config property after close() is called'
5757
);
5858
});
59+
60+
it('should detect changes written via temp-file + rename (atomic write)', async () => {
61+
const initial = { foo: 'bar' };
62+
writeFileSync(this.configFilePath, stringify(initial));
63+
const configWatcher = new RootConfigWatcher();
64+
65+
const [readyValue] = await configWatcher.ready;
66+
assert.deepEqual(readyValue, initial, 'watcher should pick up initial config');
67+
68+
const updated = { foo: 'baz' };
69+
const tempPath = `${this.configFilePath}.${process.pid}.${Date.now()}.tmp`;
70+
writeFileSync(tempPath, stringify(updated));
71+
renameSync(tempPath, this.configFilePath);
72+
73+
const [changeValue] = await once(configWatcher, 'change');
74+
assert.deepEqual(changeValue, updated, 'watcher should fire change after atomic rename');
75+
76+
configWatcher.close();
77+
});
5978
});

0 commit comments

Comments
 (0)