Skip to content

Commit 9ecf164

Browse files
committed
Fixed wrong update_time in server
1 parent 26b9163 commit 9ecf164

8 files changed

Lines changed: 494 additions & 0 deletions

File tree

docs/architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,10 @@ Nuances:
329329
6. On success:
330330
- remove old `results`,
331331
- move `temp_results` to `results`.
332+
7. After successful output publish, parse pipeline writes `results/parsing_status.json` with committed `updateTime`.
333+
- `updateTime` value is the `replaysListPreparedAt` snapshot captured at parse-run start.
334+
- This metadata is committed only on successful full parse run; failed runs must not overwrite previous committed status.
335+
- If parse-run-start snapshot is missing, committed status is also left unchanged.
332336

333337
Nuance: output generation is mostly sync (`writeFileSync`, `mkdirSync`, `moveSync`).
334338

@@ -390,6 +394,7 @@ Nuance: `processRawReplays` relies on sequential `for ... of` + `await` over rep
390394
6. Runtime state lives in `~/sg_stats`, not repo-local paths. Repo root `config/` directory is a reference copy; runtime code reads from `~/sg_stats/config/`.
391395
7. Logger (see section 5) uses `pino.multistream()` combining two async `pino.transport()` instances. File targets use `dedupe: true` (each entry goes to exactly one file target). Log folder is not cleared on restart — runs within the same minute append to existing files.
392396
8. All game types are parsed concurrently via `Promise.all` sharing one `WorkerPool`, which means worker contention across game types is possible under heavy load.
397+
9. Do not derive public parse `update_time` from live `replaysList.json`: replay list refresh can complete before parse run finishes. Use committed `results/parsing_status.json` instead.
393398

394399
## 17. Fast Code Reading Order for New Contributors
395400

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Committed Update Time Design
2+
3+
## Context
4+
Current `update_time` can move forward too early because `replaysList.json` is refreshed before parsing finishes. This creates a mismatch between displayed update timestamp and actually published statistics.
5+
6+
## Goal
7+
Expose `update_time` as the `prepareReplaysList` start timestamp of the replay list actually used by the last successful full parsing run, and publish it only after successful parsing completion.
8+
9+
## Confirmed Semantics
10+
1. `update_time` value source: `replaysListPreparedAt` captured from `replaysList.json` at parse-run start.
11+
2. Visibility rule: publish this value only after full run success (`parse + stats + output`).
12+
3. Failure rule: if run fails, keep previously published `update_time` unchanged.
13+
14+
## Considered Approaches
15+
1. Use `stats.zip` mtime only.
16+
- Pros: minimal changes.
17+
- Cons: wrong semantics (file timestamp != replay list snapshot timestamp).
18+
2. Read live `replaysList.json` from server.
19+
- Pros: easy implementation.
20+
- Cons: timestamp can advance before parsing completes.
21+
3. Persist committed parsing status artifact after successful run.
22+
- Pros: correct semantics and stable behavior during long/failed runs.
23+
- Cons: adds one metadata file.
24+
25+
## Selected Approach
26+
Use a committed metadata artifact in `results`, e.g. `results/parsing_status.json`, written only after successful full run.
27+
28+
## Data Model
29+
`results/parsing_status.json`:
30+
31+
```json
32+
{
33+
"updateTime": "2026-02-15T12:34:56.000Z"
34+
}
35+
```
36+
37+
## Runtime Flow
38+
1. Parse run starts.
39+
2. Parser reads `replaysList.json` and snapshots `replaysListPreparedAt` into run-local variable.
40+
3. Parser completes full run and publishes output.
41+
4. Parser writes `results/parsing_status.json` atomically with `updateTime` from step 2.
42+
5. Server `/parsing_status` returns `update_date` from `results/parsing_status.json`.
43+
44+
## Fallback Policy (Server)
45+
1. `results/parsing_status.json.updateTime` when valid.
46+
2. `results/stats.zip` mtime when status file missing/invalid.
47+
3. `new Date()` as final fallback.
48+
49+
## Error Handling
50+
1. Missing/invalid `replaysListPreparedAt` at parse start: parser writes `null` or omits update in committed status based on implementation policy.
51+
2. Parsing failure: do not update committed status file.
52+
3. Corrupted status file: server falls back safely.
53+
54+
## Testing Scope
55+
1. Parser unit tests for status commit on success only.
56+
2. Parser failure-path test: committed status unchanged.
57+
3. Server date utility tests for normal path and all fallback branches.
58+
4. Schedule-level behavior check: while parsing, `status=parsing` but `update_date` stays previous committed value.
59+
60+
## Non-Goals
61+
1. Changing replay list collection timing.
62+
2. Redefining parsing job orchestration.
63+
3. Adding new API fields beyond current `update_date` requirement.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# Committed Update Time Implementation Plan
2+
3+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4+
5+
**Goal:** Publish `/parsing_status.update_date` only after a successful full parse run, while preserving the original replay-list snapshot timestamp (`replaysListPreparedAt`) used by that run.
6+
7+
**Architecture:** Capture run-local `replaysListPreparedAt` at parse start in `replays-parser`, commit it into `results/parsing_status.json` only after successful output generation, and make `server` read update time from that committed artifact. Keep strict fallback behavior for bootstrap and corrupted metadata cases.
8+
9+
**Tech Stack:** TypeScript (replays-parser), Node.js CommonJS (server), Jest, node:test, fs-extra.
10+
11+
---
12+
13+
### Task 1: Add committed parsing status utilities in replays-parser
14+
15+
**Files:**
16+
- Create: `/home/afgan0r/Projects/SolidGames/replays-parser/src/0 - utils/parsingStatus.ts`
17+
- Modify: `/home/afgan0r/Projects/SolidGames/replays-parser/src/0 - utils/paths.ts`
18+
19+
**Step 1: Write the failing test**
20+
21+
- Create parser utility tests that expect:
22+
1. read run snapshot from `replaysList.json.replaysListPreparedAt`
23+
2. write `results/parsing_status.json` atomically
24+
25+
**Step 2: Run test to verify it fails**
26+
27+
Run: `npm run test -- src/!tests/unit-tests/0 - utils/parsingStatus.test.ts`
28+
Expected: FAIL (module/functions missing)
29+
30+
**Step 3: Write minimal implementation**
31+
32+
- Add `parsingStatusPath` in `paths.ts` pointing to `results/parsing_status.json`.
33+
- Implement in `parsingStatus.ts`:
34+
1. `readRunReplayListPreparedAt(): string | null`
35+
2. `commitParsingStatus(updateTime: string | null): void`
36+
3. Atomic write via temp file + rename.
37+
38+
**Step 4: Run test to verify it passes**
39+
40+
Run: `npm run test -- src/!tests/unit-tests/0 - utils/parsingStatus.test.ts`
41+
Expected: PASS
42+
43+
**Step 5: Commit**
44+
45+
```bash
46+
git add src/0\ -\ utils/parsingStatus.ts src/0\ -\ utils/paths.ts src/!tests/unit-tests/0\ -\ utils/parsingStatus.test.ts
47+
git commit -m "feat: add committed parsing status utility"
48+
```
49+
50+
### Task 2: Wire committed status into parse pipeline
51+
52+
**Files:**
53+
- Modify: `/home/afgan0r/Projects/SolidGames/replays-parser/src/index.ts`
54+
- Test: `/home/afgan0r/Projects/SolidGames/replays-parser/src/!tests/unit-tests/schedule.test.ts`
55+
56+
**Step 1: Write the failing test**
57+
58+
- Add test that on successful parse run status commit is called with run snapshot.
59+
- Add test that on failure commit is not called.
60+
61+
**Step 2: Run test to verify it fails**
62+
63+
Run: `npm run test -- src/!tests/unit-tests/schedule.test.ts`
64+
Expected: FAIL (missing commit behavior)
65+
66+
**Step 3: Write minimal implementation**
67+
68+
In `src/index.ts`:
69+
1. read run snapshot before heavy parsing starts
70+
2. after successful `generateOutput`, call `commitParsingStatus(runSnapshot)`
71+
3. keep failure path unchanged (no commit)
72+
73+
**Step 4: Run test to verify it passes**
74+
75+
Run: `npm run test -- src/!tests/unit-tests/schedule.test.ts`
76+
Expected: PASS
77+
78+
**Step 5: Commit**
79+
80+
```bash
81+
git add src/index.ts src/!tests/unit-tests/schedule.test.ts
82+
git commit -m "feat: commit update time only after successful parse"
83+
```
84+
85+
### Task 3: Switch server update_date to committed status artifact
86+
87+
**Files:**
88+
- Modify: `/home/afgan0r/Projects/SolidGames/server/src/utils/date.js`
89+
- Modify: `/home/afgan0r/Projects/SolidGames/server/src/server.js`
90+
- Test: `/home/afgan0r/Projects/SolidGames/server/src/utils/date.test.js`
91+
92+
**Step 1: Write the failing test**
93+
94+
Add/adjust tests for `getParsingStatusUpdateDate(listsPath, resultsPath)`:
95+
1. returns `parsing_status.json.updateTime` when valid
96+
2. falls back to `stats.zip` mtime when status file missing/invalid
97+
3. falls back to `new Date()` when all unavailable
98+
99+
**Step 2: Run test to verify it fails**
100+
101+
Run: `npm test`
102+
Expected: FAIL on old fallback logic
103+
104+
**Step 3: Write minimal implementation**
105+
106+
In `date.js`:
107+
1. read `results/parsing_status.json`
108+
2. validate `updateTime` as date
109+
3. fallback chain: status file -> `stats.zip` mtime -> now
110+
111+
In `server.js`:
112+
- keep `/parsing_status` using `getParsingStatusUpdateDate(listsPath, resultsPath)`
113+
114+
**Step 4: Run test to verify it passes**
115+
116+
Run: `npm test`
117+
Expected: PASS
118+
119+
**Step 5: Commit**
120+
121+
```bash
122+
git add src/utils/date.js src/server.js src/utils/date.test.js
123+
git commit -m "feat: read parsing update time from committed status file"
124+
```
125+
126+
### Task 4: Cross-repo verification and docs refresh
127+
128+
**Files:**
129+
- Modify: `/home/afgan0r/Projects/SolidGames/replays-parser/docs/architecture.md`
130+
- Modify (if needed): `/home/afgan0r/Projects/SolidGames/replays-parser/docs/plans/2026-02-15-committed-update-time-design.md`
131+
132+
**Step 1: Write failing verification expectation**
133+
134+
- Define acceptance criteria:
135+
1. During parsing: `status=parsing`, `update_date` unchanged from previous success
136+
2. After successful run: `update_date` equals committed run snapshot (`replaysListPreparedAt` captured at run start)
137+
138+
**Step 2: Run verification commands**
139+
140+
Replays parser:
141+
- `npm run lint`
142+
- `npm run test`
143+
- `npm run build-dist`
144+
145+
Server:
146+
- `npm test`
147+
148+
Expected: all PASS
149+
150+
**Step 3: Update architecture docs**
151+
152+
- Document committed status artifact and fallback chain.
153+
154+
**Step 4: Re-run verification**
155+
156+
Run same commands as step 2.
157+
Expected: all PASS
158+
159+
**Step 5: Commit**
160+
161+
```bash
162+
git add docs/architecture.md docs/plans/2026-02-15-committed-update-time-design.md
163+
git commit -m "docs: describe committed update time semantics"
164+
```
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import path from 'path';
2+
3+
import fs from 'fs-extra';
4+
5+
import { commitParsingStatus, readRunReplayListPreparedAt } from '../../../0 - utils/parsingStatus';
6+
import { parsingStatusPath, replaysListPath } from '../../../0 - utils/paths';
7+
8+
jest.mock('fs-extra', () => ({
9+
readFileSync: jest.fn(),
10+
ensureDirSync: jest.fn(),
11+
writeFileSync: jest.fn(),
12+
moveSync: jest.fn(),
13+
}));
14+
15+
const mockedFs = fs as unknown as {
16+
readFileSync: jest.Mock;
17+
ensureDirSync: jest.Mock;
18+
writeFileSync: jest.Mock;
19+
moveSync: jest.Mock;
20+
};
21+
22+
describe('parsingStatus utils', () => {
23+
beforeEach(() => {
24+
jest.restoreAllMocks();
25+
mockedFs.readFileSync.mockReset();
26+
mockedFs.ensureDirSync.mockReset();
27+
mockedFs.writeFileSync.mockReset();
28+
mockedFs.moveSync.mockReset();
29+
});
30+
31+
test('readRunReplayListPreparedAt returns value when present', () => {
32+
mockedFs.readFileSync.mockReturnValue(
33+
JSON.stringify({ replaysListPreparedAt: '2026-02-15T12:00:00.000Z' }),
34+
);
35+
36+
expect(readRunReplayListPreparedAt()).toBe('2026-02-15T12:00:00.000Z');
37+
expect(mockedFs.readFileSync).toHaveBeenCalledWith(replaysListPath, 'utf8');
38+
});
39+
40+
test('readRunReplayListPreparedAt returns null when file missing', () => {
41+
mockedFs.readFileSync.mockImplementation(() => {
42+
throw new Error('ENOENT');
43+
});
44+
45+
expect(readRunReplayListPreparedAt()).toBeNull();
46+
});
47+
48+
test('readRunReplayListPreparedAt returns null when JSON is invalid', () => {
49+
mockedFs.readFileSync.mockReturnValue('{ invalid json');
50+
51+
expect(readRunReplayListPreparedAt()).toBeNull();
52+
});
53+
54+
test('readRunReplayListPreparedAt returns null when field is missing', () => {
55+
mockedFs.readFileSync.mockReturnValue(JSON.stringify({ anotherField: 'value' }));
56+
57+
expect(readRunReplayListPreparedAt()).toBeNull();
58+
});
59+
60+
test('commitParsingStatus writes atomic temp file then moves to final path with expected payload', () => {
61+
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
62+
63+
commitParsingStatus('2026-02-15T13:00:00.000Z');
64+
65+
const expectedTempPath = `${parsingStatusPath}.tmp-${process.pid}-1700000000000`;
66+
const expectedPayload = JSON.stringify({ updateTime: '2026-02-15T13:00:00.000Z' });
67+
68+
expect(mockedFs.ensureDirSync).toHaveBeenCalledWith(path.dirname(parsingStatusPath));
69+
expect(mockedFs.ensureDirSync.mock.invocationCallOrder[0]).toBeLessThan(
70+
mockedFs.writeFileSync.mock.invocationCallOrder[0],
71+
);
72+
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expectedTempPath, expectedPayload);
73+
expect(mockedFs.moveSync).toHaveBeenCalledWith(
74+
expectedTempPath,
75+
parsingStatusPath,
76+
{ overwrite: true },
77+
);
78+
expect(mockedFs.writeFileSync.mock.invocationCallOrder[0]).toBeLessThan(
79+
mockedFs.moveSync.mock.invocationCallOrder[0],
80+
);
81+
82+
nowSpy.mockRestore();
83+
});
84+
85+
test('commitParsingStatus serializes null updateTime and preserves operation ordering', () => {
86+
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1700000000001);
87+
88+
commitParsingStatus(null);
89+
90+
const expectedTempPath = `${parsingStatusPath}.tmp-${process.pid}-1700000000001`;
91+
const expectedPayload = JSON.stringify({ updateTime: null });
92+
93+
expect(mockedFs.writeFileSync).toHaveBeenCalledWith(expectedTempPath, expectedPayload);
94+
expect(mockedFs.ensureDirSync.mock.invocationCallOrder[0]).toBeLessThan(
95+
mockedFs.writeFileSync.mock.invocationCallOrder[0],
96+
);
97+
expect(mockedFs.writeFileSync.mock.invocationCallOrder[0]).toBeLessThan(
98+
mockedFs.moveSync.mock.invocationCallOrder[0],
99+
);
100+
101+
nowSpy.mockRestore();
102+
});
103+
104+
test('commitParsingStatus rethrows when moveSync fails', () => {
105+
const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(1700000000002);
106+
107+
mockedFs.moveSync.mockImplementation(() => {
108+
throw new Error('rename failed');
109+
});
110+
111+
expect(() => {
112+
commitParsingStatus('2026-02-15T13:00:00.000Z');
113+
}).toThrow('rename failed');
114+
115+
nowSpy.mockRestore();
116+
});
117+
});

0 commit comments

Comments
 (0)