dsh-pause-resume 0.1.0 → 0.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/README.md +67 -35
- package/client.js +101 -28
- package/index.js +102 -31
- package/lib/engine.js +235 -72
- package/lib/heartbeat.js +180 -49
- package/lib/probe.js +234 -113
- package/lib/snapshot.js +143 -78
- package/package.json +2 -2
package/lib/snapshot.js
CHANGED
|
@@ -1,93 +1,136 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
5
|
+
* The durable half of the pause button: one plan file, a per-plan restore journal, one archive per
|
|
6
|
+
* consumed plan, and the last restore record.
|
|
7
7
|
*
|
|
8
|
-
* Every write is temp
|
|
9
|
-
* leaves the previous complete
|
|
10
|
-
*
|
|
11
|
-
* a state it could not record.
|
|
8
|
+
* Every write is temp file + fsync + rename + directory fsync, so a reader never sees half a plan
|
|
9
|
+
* and a crash leaves the previous complete file in place. Nothing here throws: a disk that cannot
|
|
10
|
+
* be written answers `null`, and the caller decides whether to stay in its current phase instead
|
|
11
|
+
* of claiming a state it could not record.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
|
|
14
|
+
const UNSAFE = /[^A-Za-z0-9._-]/gu
|
|
15
|
+
|
|
16
|
+
let counter = 0
|
|
17
|
+
|
|
18
|
+
function sanitize(value) {
|
|
19
|
+
const name = String(value ?? '').replace(UNSAFE, '_')
|
|
20
|
+
return name === '' ? '_' : name
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function closeQuietly(fsv, fd) {
|
|
24
|
+
try {
|
|
25
|
+
fsv.closeSync(fd)
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createStore(dir, options = {}) {
|
|
30
|
+
const fsv = options.fs ?? fs
|
|
31
|
+
const now = typeof options.now === 'function' ? options.now : Date.now
|
|
32
|
+
const log = typeof options.log === 'function' ? options.log : () => {}
|
|
33
|
+
|
|
34
|
+
const snapshotPath = join(dir, 'snapshot.json')
|
|
35
|
+
const journalDir = join(dir, 'journal')
|
|
36
|
+
const restoresDir = join(dir, 'restores')
|
|
37
|
+
const lastRestorePath = join(dir, 'last-restore.json')
|
|
38
|
+
|
|
39
|
+
const journalPath = (planId) => join(journalDir, `${sanitize(planId)}.jsonl`)
|
|
40
|
+
|
|
41
|
+
/** Flush a directory entry, so the rename that just happened survives a power loss too. */ // ui-copy-ignore
|
|
42
|
+
function syncDir(path) {
|
|
43
|
+
let fd = null
|
|
21
44
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return false
|
|
45
|
+
fd = fsv.openSync(path, 'r')
|
|
46
|
+
fsv.fsyncSync(fd)
|
|
47
|
+
} finally {
|
|
48
|
+
if (fd !== null) closeQuietly(fsv, fd)
|
|
27
49
|
}
|
|
28
50
|
}
|
|
29
51
|
|
|
30
|
-
function
|
|
52
|
+
function atomicWrite(target, value) {
|
|
53
|
+
let body = null
|
|
31
54
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (parsed.status !== 'paused' && parsed.status !== 'pausing') return null
|
|
37
|
-
return parsed
|
|
38
|
-
} catch {
|
|
39
|
-
return null
|
|
55
|
+
body = JSON.stringify(value)
|
|
56
|
+
} catch (error) {
|
|
57
|
+
log(`cannot serialize ${target}: ${error?.message ?? error}`)
|
|
58
|
+
return false
|
|
40
59
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
function write(value) {
|
|
44
|
-
if (!ensure()) return null
|
|
45
|
-
const body = JSON.stringify({ ...value, version: 1 })
|
|
46
|
-
const tmp = `${file}.${process.pid}.tmp`
|
|
60
|
+
const tmp = `${target}.${process.pid}.${counter++}.tmp`
|
|
47
61
|
let fd = null
|
|
62
|
+
let renamed = false
|
|
48
63
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
fsv.mkdirSync(dirname(target), { recursive: true })
|
|
65
|
+
fd = fsv.openSync(tmp, 'w')
|
|
66
|
+
fsv.writeSync(fd, body)
|
|
67
|
+
fsv.fsyncSync(fd)
|
|
68
|
+
const opened = fd
|
|
53
69
|
fd = null
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
fsv.closeSync(opened)
|
|
71
|
+
fsv.renameSync(tmp, target)
|
|
72
|
+
renamed = true
|
|
73
|
+
syncDir(dirname(target))
|
|
74
|
+
return true
|
|
75
|
+
} catch (error) {
|
|
76
|
+
log(`cannot write ${target}: ${error?.message ?? error}`)
|
|
77
|
+
return false
|
|
78
|
+
} finally {
|
|
79
|
+
if (fd !== null) closeQuietly(fsv, fd)
|
|
80
|
+
if (renamed === false) {
|
|
58
81
|
try {
|
|
59
|
-
|
|
82
|
+
fsv.rmSync(tmp, { force: true })
|
|
60
83
|
} catch {}
|
|
61
84
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function read() {
|
|
89
|
+
let parsed = null
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(fsv.readFileSync(snapshotPath, 'utf8'))
|
|
92
|
+
} catch {
|
|
65
93
|
return null
|
|
66
94
|
}
|
|
95
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
96
|
+
if (parsed.version !== 2) return null
|
|
97
|
+
if (parsed.status !== 'pausing' && parsed.status !== 'paused') return null
|
|
98
|
+
return parsed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function write(plan) {
|
|
102
|
+
if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) return null
|
|
103
|
+
const record = { ...plan, version: 2 }
|
|
104
|
+
return atomicWrite(snapshotPath, record) ? record : null
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!ensure()) return null
|
|
107
|
+
function appendJournal(planId, key) {
|
|
108
|
+
let line = null
|
|
72
109
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} catch {}
|
|
110
|
+
line = JSON.stringify({ k: key })
|
|
111
|
+
} catch {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
let fd = null
|
|
79
115
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
116
|
+
fsv.mkdirSync(journalDir, { recursive: true })
|
|
117
|
+
fd = fsv.openSync(journalPath(planId), 'a')
|
|
118
|
+
fsv.writeSync(fd, `${line}\n`)
|
|
119
|
+
fsv.fsyncSync(fd)
|
|
120
|
+
return true
|
|
121
|
+
} catch (error) {
|
|
122
|
+
log(`cannot journal ${planId}: ${error?.message ?? error}`)
|
|
123
|
+
return false
|
|
124
|
+
} finally {
|
|
125
|
+
if (fd !== null) closeQuietly(fsv, fd)
|
|
126
|
+
}
|
|
83
127
|
}
|
|
84
128
|
|
|
85
|
-
|
|
86
|
-
function readJournal() {
|
|
129
|
+
function readJournal(planId) {
|
|
87
130
|
const done = new Set()
|
|
88
131
|
let raw = ''
|
|
89
132
|
try {
|
|
90
|
-
raw = readFileSync(
|
|
133
|
+
raw = fsv.readFileSync(journalPath(planId), 'utf8')
|
|
91
134
|
} catch {
|
|
92
135
|
return done
|
|
93
136
|
}
|
|
@@ -96,38 +139,60 @@ export function createStore(dir) {
|
|
|
96
139
|
try {
|
|
97
140
|
const row = JSON.parse(line)
|
|
98
141
|
if (typeof row?.k === 'string') done.add(row.k)
|
|
99
|
-
} catch {
|
|
142
|
+
} catch {
|
|
143
|
+
// A torn last line is a write that never finished, not a key that was restored.
|
|
144
|
+
}
|
|
100
145
|
}
|
|
101
146
|
return done
|
|
102
147
|
}
|
|
103
148
|
|
|
104
|
-
function
|
|
105
|
-
if (!ensure()) return false
|
|
149
|
+
function clearJournal(planId) {
|
|
106
150
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
} catch {
|
|
110
|
-
return false
|
|
111
|
-
}
|
|
151
|
+
fsv.rmSync(journalPath(planId), { force: true })
|
|
152
|
+
} catch {}
|
|
112
153
|
}
|
|
113
154
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The plan is spent: archive it, publish the record the UI reads, and only then remove it. A plan
|
|
157
|
+
* that could not be archived stays on disk, so the same work is never claimed twice.
|
|
158
|
+
*/
|
|
159
|
+
function consume(plan, summary) {
|
|
160
|
+
const source = plan !== null && typeof plan === 'object' && !Array.isArray(plan) ? plan : {}
|
|
161
|
+
const archivePath = join(restoresDir, `${sanitize(source.planId)}.json`)
|
|
162
|
+
const record = { ...source, status: 'restored', restoredAt: new Date(now()).toISOString(), summary }
|
|
163
|
+
const archived = atomicWrite(archivePath, record)
|
|
164
|
+
const published = archived && atomicWrite(lastRestorePath, record)
|
|
165
|
+
if (published === false) {
|
|
166
|
+
log('cannot archive the restored plan: the snapshot stays')
|
|
167
|
+
return { ok: false, archivePath: null }
|
|
168
|
+
}
|
|
169
|
+
const current = read()
|
|
170
|
+
// Another press may have written a newer plan while this one restored; that one is not ours.
|
|
171
|
+
if (current !== null && current.planId === source.planId) {
|
|
172
|
+
try {
|
|
173
|
+
fsv.rmSync(snapshotPath, { force: true })
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
clearJournal(source.planId)
|
|
177
|
+
return { ok: true, archivePath }
|
|
118
178
|
}
|
|
119
179
|
|
|
120
180
|
function lastRestore() {
|
|
121
181
|
try {
|
|
122
|
-
|
|
182
|
+
const parsed = JSON.parse(fsv.readFileSync(lastRestorePath, 'utf8'))
|
|
183
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null
|
|
123
184
|
} catch {
|
|
124
185
|
return null
|
|
125
186
|
}
|
|
126
187
|
}
|
|
127
188
|
|
|
128
189
|
function hasSnapshot() {
|
|
129
|
-
|
|
190
|
+
try {
|
|
191
|
+
return fsv.existsSync(snapshotPath) === true
|
|
192
|
+
} catch {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
130
195
|
}
|
|
131
196
|
|
|
132
|
-
return { dir,
|
|
197
|
+
return { dir, snapshotPath, read, write, appendJournal, readJournal, clearJournal, consume, lastRestore, hasSnapshot }
|
|
133
198
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pause-resume",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "One header button stops
|
|
5
|
+
"description": "One header button stops running turns, goals and jobs, writes a plan to disk, and restores that work after a restart or crash; the button shape is the only status.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|