Skip to content

[Backend] Data Corruption Risk: sync-leaderboard.js Writes 7 JSON Files In-Place with No Crash Recovery #182

@rishab11250

Description

@rishab11250

Description

scripts/sync-leaderboard.js writes 7 JSON files during each sync cycle using fs.writeFileSync() — all in-place overwrite. If the process crashes mid-write (GitHub Actions timeout, OOM, runner killed), the file is left as truncated/invalid JSON. Since the data repo is the single source of truth, one corrupted file = permanent data loss.

This runs on a GitHub Actions cron schedule. Actions runners can be terminated at any moment with no graceful shutdown. A crash at line 194 (writing overall.json) wipes the entire leaderboard. Previous PRs (#60, #61, #65, #66) all focused on "use strict" and process.exit — none addressed write safety.


Root Cause — 7 Unsafe Write Locations

Every write follows this identical unsafe pattern (actual code at lines 193-197):

try {
  fs.writeFileSync(
    overallFilepath,
    JSON.stringify(overallData, null, 2),
    "utf8",
  );
  console.log("Daily data saved successfully");
} catch (err) {
  console.error(`Failed to write json file: `, err.message);
  process.exit(1);
}

The catch block runs too late — the file is already truncated the moment writeFileSync opens it for writing.

Complete list of all 7 unsafe write locations in the actual file:

Line(s) File Written Crash Impact
166 daily/<YYYY-MM-DD-D>.json Lose 1 daily snapshot only
193-196 overall.json Entire leaderboard data wiped
253 daily.json Daily progress/rank change view breaks
309-312 weekly.json Weekly progress/rank change view breaks
369-372 monthly.json Monthly progress/rank change view breaks
430-444 changes.json changelog lost (least critical)
456-466 last-sync.json Sync timestamp lost (least critical)

Real Crash Scenario

  1. GitHub Actions cron triggers sync-leaderboard.js
  2. Script fetches new data for all 100+ users (~30 seconds)
  3. At line 193, fs.writeFileSync begins writing overall.json — file is truncated
  4. Runner gets preempted (common on free GitHub Actions) — process dies mid-write
  5. overall.json now contains: [{ — invalid JSON, 200KB of data gone
  6. Next sync runs: JSON.parse at line 185 throws — previousOverall becomes empty []
  7. Rank change computation at line 391-425 produces empty diffs for ALL users
  8. changes.json writes "no_changes": true every day forever — bug goes unnoticed

Proposed Solution

Step 1 — Add an atomicWrite helper above the IIFE (after line 118):

const fs = require("fs");
const path = require("path");

function atomicWrite(filePath, data) {
  const dir = path.dirname(filePath);
  const ext = path.extname(filePath);
  const base = path.basename(filePath, ext);
  const tmpPath = path.join(dir, `${base}.tmp.${process.pid}.${Date.now()}${ext}`);
  
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
  fs.renameSync(tmpPath, filePath);
}

Key details:

  • Uses process.pid + Date.now() to avoid tmp filename collisions if multiple instances run
  • Write goes to tmp file first — original file is never touched
  • renameSync is atomic on the same filesystem (POSIX guarantee, Windows NTFS guarantee)
  • If writeFileSync throws, original file is 100% intact — the tmp file is just garbage to clean up

Step 2 — Replace all 7 writes:

Change every instance of:

fs.writeFileSync(somePath, JSON.stringify(someData, null, 2), "utf8");

To:

atomicWrite(somePath, someData);

Step 3 — Add startup cleanup (insert at line 122, after DATA_DIR is defined):

// Clean up leftover tmp files from previous crashes
try {
  const tmpFiles = fs.readdirSync(DATA_DIR)
    .filter(f => f.includes('.tmp.'));
  tmpFiles.forEach(f => {
    const filePath = path.join(DATA_DIR, f);
    fs.unlinkSync(filePath);
    console.log(`Cleaned up leftover tmp file: ${f}`);
  });
} catch (err) {
  console.warn("Failed to clean tmp files:", err.message);
}

Also do the same for daily/, weekly/, monthly/ subdirectories.


Acceptance Criteria

  • atomicWrite() helper function exists and is used for all 7 write locations (lines 166, 193, 253, 309, 369, 430, 456)
  • Each write first creates a temp file, then renames — original file is never overwritten directly
  • If writeFileSync throws on the tmp file, the original file remains completely unchanged
  • JSON.stringify happens inside the helper — if it throws (circular ref, etc.), original file is safe
  • Startup cleanup removes any orphaned *.tmp.* files from the data directory and subdirectories
  • Output files are byte-for-byte identical to current output (no formatting change, no data loss)
  • Prettier passes on the modified file
  • Node.js syntax check passes: node --check scripts/sync-leaderboard.js

Affected Files

  • scripts/sync-leaderboard.js

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions