@deepblend/dsh-blender-host 0.1.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/lib/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Job Store — the durable record of a render that must survive a restart.
|
|
3
|
+
*
|
|
4
|
+
* Plane: HOST composition (SPEC §4.1 "DeepBlend Persistent Job Store", §4.3
|
|
5
|
+
* "渲染任务恢复 → Host / 需要重启后恢复"). Process-level, cross-session, on disk.
|
|
6
|
+
*
|
|
7
|
+
* WHERE IT LIVES, AND WHY NOT IN `jobs/`
|
|
8
|
+
* -------------------------------------
|
|
9
|
+
* ```
|
|
10
|
+
* <project>/
|
|
11
|
+
* jobs/<jobId>.json attempt log, one per Blender action (M1)
|
|
12
|
+
* renders/
|
|
13
|
+
* <jobId>/
|
|
14
|
+
* job.json THE RENDER JOB — this module (M3)
|
|
15
|
+
* plan.json the frame list handed to the renderer
|
|
16
|
+
* process.json the pid the CHILD claimed
|
|
17
|
+
* events.jsonl the child's fsynced per-frame journal
|
|
18
|
+
* result.json the child's bootstrap envelope
|
|
19
|
+
* frames/frame_0001.png … the deliverable's frames
|
|
20
|
+
* encoded/<jobId>.mp4 the encoded video, before publication
|
|
21
|
+
* manifest.json the render manifest for this job
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* `jobs/` holds M1's attempt log: written once at the start of an action and once
|
|
25
|
+
* at the end, because every M1/M2 action finished inside the tool call that
|
|
26
|
+
* started it. The durable render job is a different object with a different
|
|
27
|
+
* lifetime (SPEC §10.2), and `allocateJobId` counts `*.json` in `jobs/` to mint
|
|
28
|
+
* its ids — writing render jobs there would both inflate that counter and make
|
|
29
|
+
* "list this project's attempts" return two shapes.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE RECORD IS WRITTEN SO OFTEN
|
|
32
|
+
* ----------------------------------
|
|
33
|
+
* Every mutation is an atomic write of the whole document (`writeJsonAtomic`:
|
|
34
|
+
* temp file in the same directory, then `rename`). At ~29 s per frame, one write
|
|
35
|
+
* per frame costs nothing measurable, and the alternative — batching — means a
|
|
36
|
+
* kill loses the very progress the record exists to preserve. A reader therefore
|
|
37
|
+
* sees either the previous complete document or the new one, never a torn one.
|
|
38
|
+
*
|
|
39
|
+
* Owner: DeepBlend Studio — M3
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
BlenderError,
|
|
47
|
+
BlenderErrorCode,
|
|
48
|
+
RENDER_JOB_VERSION,
|
|
49
|
+
RENDER_JOB_STATUSES,
|
|
50
|
+
RENDER_JOB_TERMINAL_STATUSES,
|
|
51
|
+
canTransitionRenderJob,
|
|
52
|
+
checkTransition,
|
|
53
|
+
frameNumbers,
|
|
54
|
+
isTerminalRenderJobStatus,
|
|
55
|
+
} from '@deepblend/dsh-blender-contracts'
|
|
56
|
+
|
|
57
|
+
import { ensureDirectory, readJson, requireSafeSegment, resolveInside, writeJsonAtomic } from './paths.js'
|
|
58
|
+
|
|
59
|
+
/** Directory under the project that holds render jobs. */
|
|
60
|
+
export const RENDER_JOBS_DIRECTORY = 'renders'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A render job id, safe as a single path segment.
|
|
64
|
+
*
|
|
65
|
+
* Minted from the project and a monotonically increasing counter rather than a
|
|
66
|
+
* timestamp: the id appears in a tool result the model reasons about and in a
|
|
67
|
+
* directory a human opens, and `render-0001` reads as "the first delivery render"
|
|
68
|
+
* where `render-20260913143502-3` reads as noise.
|
|
69
|
+
*
|
|
70
|
+
* @param {number} ordinal
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function formatRenderJobId(ordinal) {
|
|
74
|
+
return `render-${String(ordinal).padStart(4, '0')}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The statuses that mean "this job still owes the project frames". */
|
|
78
|
+
/**
|
|
79
|
+
* Statuses in which a job is still LIVE — derived, not restated.
|
|
80
|
+
*
|
|
81
|
+
* This was a fourth hand-written copy of the vocabulary (`['queued', 'running', 'stopping', 'recovering']`),
|
|
82
|
+
* which is one fact in two places: a status added to `RENDER_JOB_STATUSES` and not to this list would make
|
|
83
|
+
* `_activeRenderJob` answer "no render is running" for a job that is running, and the delivery slot would be
|
|
84
|
+
* handed to a second renderer. The complement of the terminal statuses IS this list, so it is computed.
|
|
85
|
+
*/
|
|
86
|
+
export const UNFINISHED_STATUSES = Object.freeze(
|
|
87
|
+
RENDER_JOB_STATUSES.filter(status => !RENDER_JOB_TERMINAL_STATUSES.includes(status)),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
export class RenderJobStore {
|
|
91
|
+
/**
|
|
92
|
+
* @param {{ projectDirectory: (projectId: string) => string, workspaceRoot: string }} options
|
|
93
|
+
*/
|
|
94
|
+
constructor(options) {
|
|
95
|
+
this.projectDirectory = options.projectDirectory
|
|
96
|
+
this.workspaceRoot = options.workspaceRoot
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `<project>/renders` */
|
|
100
|
+
rendersRoot(projectId) {
|
|
101
|
+
return join(this.projectDirectory(projectId), RENDER_JOBS_DIRECTORY)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `<project>/renders/<jobId>` */
|
|
105
|
+
jobDirectory(projectId, jobId) {
|
|
106
|
+
requireSafeSegment(jobId, 'render job id')
|
|
107
|
+
return resolveInside(this.workspaceRoot, join(this.rendersRoot(projectId), jobId), 'render job directory')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `<project>/renders/<jobId>/frames` */
|
|
111
|
+
framesDirectory(projectId, jobId) {
|
|
112
|
+
return join(this.jobDirectory(projectId, jobId), 'frames')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Create the job directory tree. */
|
|
116
|
+
ensureJobDirectory(projectId, jobId) {
|
|
117
|
+
return ensureDirectory(this.workspaceRoot, this.jobDirectory(projectId, jobId), 'render job directory')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `<project>/renders/<jobId>/job.json` */
|
|
121
|
+
recordPath(projectId, jobId) {
|
|
122
|
+
return join(this.jobDirectory(projectId, jobId), 'job.json')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Mint the next render job id for a project.
|
|
127
|
+
*
|
|
128
|
+
* The counter is derived from the directory listing rather than stored, so it
|
|
129
|
+
* cannot drift from what exists: a deleted job frees its ordinal, and a
|
|
130
|
+
* restored project keeps the ids it shipped with.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} projectId
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
allocateJobId(projectId) {
|
|
136
|
+
const root = this.rendersRoot(projectId)
|
|
137
|
+
const existing = existsSync(root)
|
|
138
|
+
? readdirSync(root).map(name => /^render-(\d+)$/.exec(name)).filter(Boolean).map(match => Number.parseInt(match[1], 10))
|
|
139
|
+
: []
|
|
140
|
+
const next = existing.length === 0 ? 1 : Math.max(...existing) + 1
|
|
141
|
+
return formatRenderJobId(next)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Persist a render job record.
|
|
146
|
+
*
|
|
147
|
+
* Validates the status transition first. An illegal transition here is a bug in
|
|
148
|
+
* the caller, and the failure it prevents is a record in a state no reader can
|
|
149
|
+
* act on — for example `completed` with missing frames, which would make the
|
|
150
|
+
* reconciler consider the job finished.
|
|
151
|
+
*
|
|
152
|
+
* @param {object} record
|
|
153
|
+
* @param {{allowIllegalTransition?: boolean}} [options]
|
|
154
|
+
* @returns {object}
|
|
155
|
+
*/
|
|
156
|
+
write(record, options = {}) {
|
|
157
|
+
const previous = options.previous ?? null
|
|
158
|
+
if (previous !== null && !canTransitionRenderJob(previous.status, record.status)) {
|
|
159
|
+
const verdict = checkTransition(previous.status, record.status)
|
|
160
|
+
throw new BlenderError(
|
|
161
|
+
BlenderErrorCode.RENDER_JOB_STATE_INVALID,
|
|
162
|
+
`${verdict.reason} (job ${record.jobId})`,
|
|
163
|
+
{ detail: { jobId: record.jobId, from: previous.status, to: record.status } },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
this.ensureJobDirectory(record.projectId, record.jobId)
|
|
167
|
+
const next = { ...record, schemaVersion: RENDER_JOB_VERSION, updatedAt: Date.now() }
|
|
168
|
+
writeJsonAtomic(this.recordPath(record.projectId, record.jobId), next)
|
|
169
|
+
return next
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read a render job record, or throw.
|
|
174
|
+
* @param {string} projectId
|
|
175
|
+
* @param {string} jobId
|
|
176
|
+
* @returns {object}
|
|
177
|
+
*/
|
|
178
|
+
read(projectId, jobId) {
|
|
179
|
+
const path = this.recordPath(projectId, jobId)
|
|
180
|
+
const record = readJson(path)
|
|
181
|
+
if (record === null) {
|
|
182
|
+
throw new BlenderError(
|
|
183
|
+
BlenderErrorCode.RENDER_JOB_NOT_FOUND,
|
|
184
|
+
`Project "${projectId}" has no render job "${jobId}".`,
|
|
185
|
+
{ detail: { projectId, jobId } },
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
return record
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Read a record without throwing, for a scan. */
|
|
192
|
+
readSafe(projectId, jobId) {
|
|
193
|
+
return readJson(this.recordPath(projectId, jobId))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every render job id under a project, in ordinal order. */
|
|
197
|
+
listJobIds(projectId) {
|
|
198
|
+
const root = this.rendersRoot(projectId)
|
|
199
|
+
if (!existsSync(root)) return []
|
|
200
|
+
return readdirSync(root)
|
|
201
|
+
.map(name => /^render-(\d+)$/.exec(name))
|
|
202
|
+
.filter(match => match !== null)
|
|
203
|
+
.map(match => ({ id: match[0], ordinal: Number.parseInt(match[1], 10) }))
|
|
204
|
+
.sort((left, right) => left.ordinal - right.ordinal)
|
|
205
|
+
.map(entry => entry.id)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every render job record in a project, newest last.
|
|
210
|
+
* @param {string} projectId
|
|
211
|
+
* @returns {object[]}
|
|
212
|
+
*/
|
|
213
|
+
list(projectId) {
|
|
214
|
+
const records = []
|
|
215
|
+
for (const jobId of this.listJobIds(projectId)) {
|
|
216
|
+
const record = this.readSafe(projectId, jobId)
|
|
217
|
+
if (record !== null) records.push(record)
|
|
218
|
+
}
|
|
219
|
+
return records
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The jobs that still owe frames — the reconciler's input set.
|
|
224
|
+
*
|
|
225
|
+
* A record that cannot be PARSED is included as a job id with a null record, so
|
|
226
|
+
* a corrupt record is surfaced rather than skipped. A scan that silently drops
|
|
227
|
+
* what it cannot read reports "nothing unfinished" for a directory that has an
|
|
228
|
+
* unfinished job in it, which is the one answer restart recovery must never give.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} projectId
|
|
231
|
+
* @returns {{jobId: string, record: object|null}[]}
|
|
232
|
+
*/
|
|
233
|
+
unfinished(projectId) {
|
|
234
|
+
const found = []
|
|
235
|
+
for (const jobId of this.listJobIds(projectId)) {
|
|
236
|
+
const record = this.readSafe(projectId, jobId)
|
|
237
|
+
if (record === null) {
|
|
238
|
+
found.push({ jobId, record: null })
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (!isTerminalRenderJobStatus(record.status)) found.push({ jobId, record })
|
|
242
|
+
}
|
|
243
|
+
return found
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The unfinished jobs of every project under a root.
|
|
248
|
+
*
|
|
249
|
+
* SPEC §10.3 says the reconciler runs "at Host startup", and a scan of every
|
|
250
|
+
* project is the honest reading: an unfinished render is exactly the thing a
|
|
251
|
+
* user comes back to the workbench to find, and a project they open first would
|
|
252
|
+
* otherwise be the only one that reports it. The scan touches one small JSON
|
|
253
|
+
* document per job plus a directory listing per project, so its cost is bounded
|
|
254
|
+
* by the number of projects, not by the number of frames.
|
|
255
|
+
*
|
|
256
|
+
* @param {string[]} projectIds
|
|
257
|
+
* @returns {{projectId: string, jobId: string, record: object|null}[]}
|
|
258
|
+
*/
|
|
259
|
+
unfinishedAcross(projectIds) {
|
|
260
|
+
const found = []
|
|
261
|
+
for (const projectId of projectIds) {
|
|
262
|
+
for (const entry of this.unfinished(projectId)) {
|
|
263
|
+
found.push({ projectId, jobId: entry.jobId, record: entry.record })
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return found
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The record's own frame range as an explicit list.
|
|
271
|
+
* @param {object} record
|
|
272
|
+
* @returns {number[]}
|
|
273
|
+
*/
|
|
274
|
+
expectedFrames(record) {
|
|
275
|
+
const { frames, error } = frameNumbers(record.frameStart, record.frameEnd)
|
|
276
|
+
if (error !== null) {
|
|
277
|
+
throw new BlenderError(
|
|
278
|
+
BlenderErrorCode.RENDER_RANGE_INVALID,
|
|
279
|
+
`render job ${record.jobId} has ${error}`,
|
|
280
|
+
{ detail: { jobId: record.jobId, frameStart: record.frameStart, frameEnd: record.frameEnd } },
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
return frames
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** True when the record is finished and owes nothing. */
|
|
287
|
+
static isTerminal(record) {
|
|
288
|
+
return RENDER_JOB_TERMINAL_STATUSES.includes(record?.status)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render journal tail — reading the child's progress without trusting it.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE JOURNAL IS, AND WHAT IT IS NOT
|
|
5
|
+
* ---------------------------------------
|
|
6
|
+
* `bootstrap.py --events <path>` appends one JSON line per event and fsyncs each
|
|
7
|
+
* one, so the journal survives a `kill -9` of the Host. That makes it the right
|
|
8
|
+
* channel for the two facts disk cannot express on its own:
|
|
9
|
+
*
|
|
10
|
+
* - which pid is rendering (`process.json`, written before any bpy work), and
|
|
11
|
+
* - that frame N has been written AND verified by the renderer.
|
|
12
|
+
*
|
|
13
|
+
* It is **not** the authority for which frames exist. A frame can be present while
|
|
14
|
+
* its journal line is missing (killed between the file landing and the line being
|
|
15
|
+
* flushed), and a file can exist while not being a frame (killed mid-write). So
|
|
16
|
+
* this module only says "the renderer claims frame N landed"; the caller verifies
|
|
17
|
+
* that frame against its bytes before counting it (see `frame-ledger.js`).
|
|
18
|
+
*
|
|
19
|
+
* THE TORN-LINE RULE
|
|
20
|
+
* ------------------
|
|
21
|
+
* The journal is appended to by a process that can be killed at any instant, so
|
|
22
|
+
* the last line may be a partial write. The reader must NOT advance past a line it
|
|
23
|
+
* could not parse: the rest of that line arrives a moment later, and an offset
|
|
24
|
+
* that skipped it would lose an event permanently. So the offset advances only to
|
|
25
|
+
* the end of the last COMPLETE, parsed line.
|
|
26
|
+
*
|
|
27
|
+
* `tornLineSeen` says "the file ended mid-line as of the last read that reached the
|
|
28
|
+
* end of it", and it is recomputed on every such read rather than latched: a tail
|
|
29
|
+
* that completes on the next poll means the journal is whole, and a latched flag
|
|
30
|
+
* would go on accusing the renderer of losing an event that arrived.
|
|
31
|
+
*
|
|
32
|
+
* That flag is what a kill between two writes leaves behind — and it is ALSO what a
|
|
33
|
+
* line still being written looks like, so at read time the two are indistinguishable.
|
|
34
|
+
* The flag is a fact about the file; `tornLineIsEvidence({stopped})` is where the
|
|
35
|
+
* fact becomes reportable, and it takes the one thing only the caller knows: whether
|
|
36
|
+
* the writer has stopped for good.
|
|
37
|
+
*
|
|
38
|
+
* A COMPLETE line that does not parse is a different fact — a writer bug, not a
|
|
39
|
+
* torn kill — so it gets its own `unparseable-line` marker and does NOT raise the
|
|
40
|
+
* torn flag. Reporting one as the other would name the wrong cause.
|
|
41
|
+
*
|
|
42
|
+
* Owner: DeepBlend Studio — M3
|
|
43
|
+
* Plane: Host composition
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs'
|
|
47
|
+
|
|
48
|
+
import { BlenderWarningCode, warning } from '@deepblend/dsh-blender-contracts'
|
|
49
|
+
|
|
50
|
+
/** Cap on one read, so a pathological journal cannot be read in one allocation. */
|
|
51
|
+
const MAX_READ_BYTES = 4 * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
export class JournalTail {
|
|
54
|
+
/** @param {string} path */
|
|
55
|
+
constructor(path) {
|
|
56
|
+
this.path = path
|
|
57
|
+
this.offset = 0
|
|
58
|
+
/** Events already returned, so a caller can ask for the whole set. */
|
|
59
|
+
this.events = []
|
|
60
|
+
/** The file ended mid-line as of the last read that reached the end of it. */
|
|
61
|
+
this.tornLineSeen = false
|
|
62
|
+
/** The torn tail has already been reported, so it is not reported again. */
|
|
63
|
+
this.tornReported = false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is the torn tail EVIDENCE yet — that is, should the caller report it?
|
|
68
|
+
*
|
|
69
|
+
* `tornLineSeen` is a fact about the file; this is where the fact becomes
|
|
70
|
+
* reportable, and it needs one thing only the caller knows: whether the writer has
|
|
71
|
+
* stopped. Mid-render an incomplete last line is the ordinary state of a line being
|
|
72
|
+
* written, and a reader that called every one of those a kill would put a false
|
|
73
|
+
* accusation in the log of every healthy render. Once the process is gone, the same
|
|
74
|
+
* bytes mean the last event never arrived.
|
|
75
|
+
*
|
|
76
|
+
* True at most once per journal: a poll repeats every second, and a diagnostic that
|
|
77
|
+
* repeats is noise. A `false` answer is not final, though — the caller asks again on
|
|
78
|
+
* the next read, which is how a tail that is still being written gets its chance to
|
|
79
|
+
* complete, and how one that never does gets reported once the writer is gone.
|
|
80
|
+
*
|
|
81
|
+
* @param {{stopped: boolean}} input - whether the writer has stopped for good.
|
|
82
|
+
* @returns {boolean}
|
|
83
|
+
*/
|
|
84
|
+
tornLineIsEvidence(input) {
|
|
85
|
+
if (this.tornReported === true || this.tornLineSeen !== true) return false
|
|
86
|
+
if (input?.stopped !== true) return false
|
|
87
|
+
this.tornReported = true
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read every complete line appended since the last call.
|
|
93
|
+
*
|
|
94
|
+
* @returns {object[]} the newly parsed events, in order.
|
|
95
|
+
*/
|
|
96
|
+
drain() {
|
|
97
|
+
if (!existsSync(this.path)) return []
|
|
98
|
+
let size
|
|
99
|
+
try {
|
|
100
|
+
size = statSync(this.path).size
|
|
101
|
+
} catch {
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
if (size <= this.offset) return []
|
|
105
|
+
|
|
106
|
+
const length = Math.min(size - this.offset, MAX_READ_BYTES)
|
|
107
|
+
const buffer = Buffer.alloc(length)
|
|
108
|
+
let descriptor = null
|
|
109
|
+
try {
|
|
110
|
+
descriptor = openSync(this.path, 'r')
|
|
111
|
+
readSync(descriptor, buffer, 0, length, this.offset)
|
|
112
|
+
} catch {
|
|
113
|
+
return []
|
|
114
|
+
} finally {
|
|
115
|
+
if (descriptor !== null) {
|
|
116
|
+
try {
|
|
117
|
+
closeSync(descriptor)
|
|
118
|
+
} catch {
|
|
119
|
+
// The descriptor was opened here and nothing else closes it, so this catch is not for "already
|
|
120
|
+
// closed" — it is because a throw inside `finally` REPLACES the error being propagated, and the
|
|
121
|
+
// caller needs the read failure rather than a close failure.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Did this read reach the end of the FILE, or stop at the cap? Only a read that
|
|
127
|
+
// reached the end can say anything about the tail: with bytes still unread, an
|
|
128
|
+
// incomplete line here is the cap, not a torn write.
|
|
129
|
+
const readToEnd = this.offset + length === size
|
|
130
|
+
|
|
131
|
+
const text = buffer.toString('utf8')
|
|
132
|
+
const lastNewline = text.lastIndexOf('\n')
|
|
133
|
+
if (lastNewline < 0) {
|
|
134
|
+
// Nothing complete yet. The offset stays put so the partial line is re-read
|
|
135
|
+
// once it is finished — skipping it would lose the event for good.
|
|
136
|
+
if (readToEnd) this.tornLineSeen = text.length > 0
|
|
137
|
+
return []
|
|
138
|
+
}
|
|
139
|
+
// The flag describes the file as it stands NOW, so it is recomputed rather than
|
|
140
|
+
// latched — and the ordinary torn case is a NON-EMPTY TAIL after the last newline,
|
|
141
|
+
// which is what a kill between two writes leaves behind. Missing that case was a
|
|
142
|
+
// real defect: the flag was raised only for a buffer with no complete line at all,
|
|
143
|
+
// so a journal cut after frame 30 of 450 looked intact, and the host's "the journal
|
|
144
|
+
// ends mid-line" line could not fire for the case it exists for. Recomputing also
|
|
145
|
+
// keeps the opposite lie out: a tail that completes on the next poll means the
|
|
146
|
+
// journal is whole, and a latched flag would still be accusing the renderer of
|
|
147
|
+
// losing an event that arrived.
|
|
148
|
+
if (readToEnd) this.tornLineSeen = lastNewline < text.length - 1
|
|
149
|
+
|
|
150
|
+
const complete = text.slice(0, lastNewline + 1)
|
|
151
|
+
const consumedBytes = Buffer.byteLength(complete, 'utf8')
|
|
152
|
+
const fresh = []
|
|
153
|
+
for (const line of complete.split('\n')) {
|
|
154
|
+
if (line.trim().length === 0) continue
|
|
155
|
+
try {
|
|
156
|
+
fresh.push(JSON.parse(line))
|
|
157
|
+
} catch {
|
|
158
|
+
// A line that is complete AND unparseable is not a torn write; it is a
|
|
159
|
+
// writer bug. Recorded as a marker rather than dropped, because a journal
|
|
160
|
+
// that silently loses an event is indistinguishable from one that never
|
|
161
|
+
// had it. Deliberately NOT `tornLineSeen`: the two have different causes,
|
|
162
|
+
// and the caller says different things about them.
|
|
163
|
+
fresh.push({ type: 'unparseable-line' })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.offset += consumedBytes
|
|
167
|
+
this.events.push(...fresh)
|
|
168
|
+
return fresh
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Per-frame durations the renderer reported, for the remaining-time estimate. */
|
|
172
|
+
frameDurations() {
|
|
173
|
+
const durations = []
|
|
174
|
+
for (const event of this.events) {
|
|
175
|
+
if (event?.type === 'frame' && Number.isFinite(event.ms)) durations.push(event.ms)
|
|
176
|
+
}
|
|
177
|
+
return durations
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Does this event claim that a frame landed?
|
|
183
|
+
*
|
|
184
|
+
* "Claims" is the operative word: the renderer says frame N is done, and the caller
|
|
185
|
+
* verifies that against the frame's BYTES before counting it (see `frame-ledger.js`).
|
|
186
|
+
* The rule lives here, next to the format, because it had two copies in the host and
|
|
187
|
+
* a copy in a test — three places to drift.
|
|
188
|
+
*
|
|
189
|
+
* (This replaces a `claimedFrames()` collector that handed back a list of unverified
|
|
190
|
+
* frame numbers. Nothing called it, which is the only reason it was harmless: the
|
|
191
|
+
* architecture says a claim is never the authority, so the collection of claims is
|
|
192
|
+
* exactly the value no caller may act on. A predicate cannot be mistaken for a
|
|
193
|
+
* ledger.)
|
|
194
|
+
*
|
|
195
|
+
* @param {unknown} event
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
export function isFrameClaim(event) {
|
|
199
|
+
return event?.type === 'frame' && Number.isSafeInteger(event.frame)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The warning this attempt earns when its journal was cut off mid-line, or `null`.
|
|
204
|
+
*
|
|
205
|
+
* WHY THE WHOLE DECISION IS HERE AND NOT AT THE CALL SITE
|
|
206
|
+
* ------------------------------------------------------
|
|
207
|
+
* It is four conditions in a row — the file ends mid-line, the writer has stopped, the kill was not
|
|
208
|
+
* one the caller asked for, and it has not been said before — and the only place that can call it is
|
|
209
|
+
* a background absorb inside a running Host. MEASURED, round 25: the end-to-end suite settles it
|
|
210
|
+
* against the FILE, but a `SIGKILL` lands on a line boundary in practice (two runs, both clean), so
|
|
211
|
+
* "torn, therefore recorded" is not reachable on demand there, and the branch that writes the warning
|
|
212
|
+
* would never be exercised. A rule whose only exercise is luck is a rule nobody has checked, so it
|
|
213
|
+
* lives where the contract suite can drive all four conditions by hand.
|
|
214
|
+
*
|
|
215
|
+
* A kill the caller ASKED for is not a finding: `cancelJob` terminates the process group on purpose,
|
|
216
|
+
* and a journal cut in half is the expected consequence of that. That check comes FIRST so a
|
|
217
|
+
* cancelled absorb does not consume the once-only flag of a journal that is genuinely torn.
|
|
218
|
+
*
|
|
219
|
+
* @param {JournalTail} journal
|
|
220
|
+
* @param {{stopped: boolean, cancelled?: boolean, jobId?: string, attemptToken?: string|null}} input
|
|
221
|
+
* @returns {{code: string, message: string, detail?: object}|null}
|
|
222
|
+
*/
|
|
223
|
+
export function incompleteJournalWarning(journal, input) {
|
|
224
|
+
if (input?.cancelled === true) return null
|
|
225
|
+
if (journal?.tornLineIsEvidence({ stopped: input?.stopped === true }) !== true) return null
|
|
226
|
+
const detail = {}
|
|
227
|
+
if (input?.jobId !== undefined) detail.jobId = input.jobId
|
|
228
|
+
if (input?.attemptToken !== undefined && input.attemptToken !== null) detail.attemptToken = input.attemptToken
|
|
229
|
+
return warning(
|
|
230
|
+
BlenderWarningCode.JOURNAL_INCOMPLETE,
|
|
231
|
+
'an attempt at this job was cut off mid-line in its event journal (a kill between the write and the ' +
|
|
232
|
+
'flush), so the last event the renderer sent never arrived. Frame progress and the resumed frame ' +
|
|
233
|
+
'set are read from the frame files themselves, so no count here is affected.',
|
|
234
|
+
Object.keys(detail).length > 0 ? detail : undefined,
|
|
235
|
+
)
|
|
236
|
+
}
|