Skip to content

Commit 310756e

Browse files
authored
fix: prevent data corruption in sync-leaderboard.js with atomic writes (#211)
Implement atomic file writes to prevent data loss when the sync process crashes mid-write (GitHub Actions runner preemption, OOM). - Add atomicWrite() helper: writes to .tmp file then atomic rename - Add startup cleanup for orphaned .tmp.* files from prior crashes - Replace all 8 unsafe fs.writeFileSync calls with atomicWrite() - updateUserHistory write also converted for consistency If a write or JSON.stringify throws, the original file is untouched because the tmp file is written first and only renamed on success. renameSync is atomic on both POSIX and NTFS (same filesystem). Fixes #182
1 parent d0da2e2 commit 310756e

1 file changed

Lines changed: 50 additions & 46 deletions

File tree

scripts/sync-leaderboard.js

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ const axios = require("axios");
44
const fs = require("fs");
55
const path = require("path");
66

7+
function atomicWrite(filePath, data) {
8+
const dir = path.dirname(filePath);
9+
const ext = path.extname(filePath);
10+
const base = path.basename(filePath, ext);
11+
const tmpPath = path.join(
12+
dir,
13+
`${base}.tmp.${process.pid}.${Date.now()}${ext}`,
14+
);
15+
16+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
17+
fs.renameSync(tmpPath, filePath);
18+
}
19+
720
async function fetchData(url) {
821
try {
922
const res = await axios.get(url, { timeout: 15000 });
@@ -69,7 +82,7 @@ function updateUserHistory(user, DATA_DIR) {
6982

7083
history.sort((a, b) => new Date(a.date) - new Date(b.date));
7184

72-
fs.writeFileSync(userHistoryPath, JSON.stringify(history, null, 2), "utf8");
85+
atomicWrite(userHistoryPath, history);
7386
}
7487

7588
function assignCompetitionRanks(sortedData) {
@@ -161,6 +174,25 @@ async function computeRankChanges(currentSorted, filename) {
161174
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..", "data");
162175
console.log(`Using data directory: ${DATA_DIR}`);
163176

177+
// Clean up leftover tmp files from previous crashes
178+
const tmpCleanupDirs = [DATA_DIR, path.join(DATA_DIR, "daily")];
179+
tmpCleanupDirs.forEach((dirPath) => {
180+
try {
181+
if (fs.existsSync(dirPath)) {
182+
const tmpFiles = fs
183+
.readdirSync(dirPath)
184+
.filter((f) => f.includes(".tmp."));
185+
tmpFiles.forEach((f) => {
186+
const filePath = path.join(dirPath, f);
187+
fs.unlinkSync(filePath);
188+
console.log(`Cleaned up leftover tmp file: ${f}`);
189+
});
190+
}
191+
} catch (err) {
192+
console.warn(`Failed to clean tmp files in ${dirPath}:`, err.message);
193+
}
194+
});
195+
164196
console.log("Loading users...");
165197
const userFilePath = path.join(DATA_DIR, "users.json");
166198
let users = [];
@@ -204,7 +236,7 @@ async function computeRankChanges(currentSorted, filename) {
204236
console.log("Writing daily data to file...");
205237
const filepath = path.join(DATA_DIR, "daily", getFileName(0));
206238
try {
207-
fs.writeFileSync(filepath, JSON.stringify(overallData, null, 2), "utf8");
239+
atomicWrite(filepath, overallData);
208240
console.log("Daily data saved successfully");
209241
} catch (err) {
210242
console.error(`Failed to write json file: `, err.message);
@@ -247,11 +279,7 @@ async function computeRankChanges(currentSorted, filename) {
247279

248280
await computeRankChanges(overallData, "overall.json");
249281
try {
250-
fs.writeFileSync(
251-
overallFilepath,
252-
JSON.stringify(overallData, null, 2),
253-
"utf8",
254-
);
282+
atomicWrite(overallFilepath, overallData);
255283
console.log("Daily data saved successfully");
256284
} catch (err) {
257285
console.error(`Failed to write json file: `, err.message);
@@ -307,7 +335,7 @@ async function computeRankChanges(currentSorted, filename) {
307335
const dailyFilepath = path.join(DATA_DIR, "daily.json");
308336
await computeRankChanges(dailyData, "daily.json");
309337
try {
310-
fs.writeFileSync(dailyFilepath, JSON.stringify(dailyData, null, 2), "utf8");
338+
atomicWrite(dailyFilepath, dailyData);
311339
console.log("Daily data saved successfully");
312340
} catch (err) {
313341
console.error(`Failed to write json file: `, err.message);
@@ -363,11 +391,7 @@ async function computeRankChanges(currentSorted, filename) {
363391
const weeklyFilepath = path.join(DATA_DIR, "weekly.json");
364392
await computeRankChanges(weeklyData, "weekly.json");
365393
try {
366-
fs.writeFileSync(
367-
weeklyFilepath,
368-
JSON.stringify(weeklyData, null, 2),
369-
"utf8",
370-
);
394+
atomicWrite(weeklyFilepath, weeklyData);
371395
console.log("Weekly data saved successfully");
372396
} catch (err) {
373397
console.error(`Failed to write json file: `, err.message);
@@ -423,11 +447,7 @@ async function computeRankChanges(currentSorted, filename) {
423447
const monthlyFilepath = path.join(DATA_DIR, "monthly.json");
424448
await computeRankChanges(monthlyData, "monthly.json");
425449
try {
426-
fs.writeFileSync(
427-
monthlyFilepath,
428-
JSON.stringify(monthlyData, null, 2),
429-
"utf8",
430-
);
450+
atomicWrite(monthlyFilepath, monthlyData);
431451
console.log("Monthly data saved successfully");
432452
} catch (err) {
433453
console.error(`Failed to write json file: `, err.message);
@@ -484,22 +504,14 @@ async function computeRankChanges(currentSorted, filename) {
484504
const noChanges =
485505
rankChanges.length === 0 && newUsers.length === 0 && totalNewSolves === 0;
486506

487-
fs.writeFileSync(
488-
changesFilepath,
489-
JSON.stringify(
490-
{
491-
sync_time: new Date().toISOString(),
492-
rank_changes: rankChanges,
493-
new_users: newUsers,
494-
total_new_solves: totalNewSolves,
495-
users_with_new_solves: usersWithNewSolves,
496-
no_changes: noChanges,
497-
},
498-
null,
499-
2,
500-
),
501-
"utf8",
502-
);
507+
atomicWrite(changesFilepath, {
508+
sync_time: new Date().toISOString(),
509+
rank_changes: rankChanges,
510+
new_users: newUsers,
511+
total_new_solves: totalNewSolves,
512+
users_with_new_solves: usersWithNewSolves,
513+
no_changes: noChanges,
514+
});
503515
console.log("changes.json saved successfully");
504516
} catch (err) {
505517
console.error("Failed to write changes.json: ", err.message);
@@ -510,18 +522,10 @@ async function computeRankChanges(currentSorted, filename) {
510522
try {
511523
const now = new Date();
512524
const nextSync = new Date(now.getTime() + 5 * 60 * 1000);
513-
fs.writeFileSync(
514-
syncFilepath,
515-
JSON.stringify(
516-
{
517-
lastSync: now.toISOString(),
518-
nextSync: nextSync.toISOString(),
519-
},
520-
null,
521-
2,
522-
),
523-
"utf8",
524-
);
525+
atomicWrite(syncFilepath, {
526+
lastSync: now.toISOString(),
527+
nextSync: nextSync.toISOString(),
528+
});
525529
console.log("Sync timestamp saved successfully");
526530
} catch (err) {
527531
console.error(`Failed to write sync file: `, err.message);

0 commit comments

Comments
 (0)