fix(build): 哈希链含尾换行;media 块 first-wins 确定性;job 复用只认 succeeded+崩溃回收;重写器边界锚定

审查六修(Fix 2/3 先于 Task 3 消费六文件落形):
- Fix 1 三哈希 = sha256(文件字节含尾换行)——哈希链与落盘文件互证;测试改按
  原始文件字节独立复算对拍
- Fix 2 media_usages orderBy(fieldPath, publicPath) + first-wins:同 fieldPath
  多资产引用取字典序首位、其余丢弃记 issue;两次物化字节一致有测试钉死
- Fix 3 job 复用只认 succeeded;failed/cancelled/stale 死行与非终态崩溃残留
  (旧行置 stale 宣判)都以 <key>#a<attempt+1> 后缀键建新行自动重跑(attempt
  延续谱系);失败终态化时清走半途 input/ 目录;两路径各有测试
- Fix 4 git rev-parse 等环境探测移出快照事务(单次探测传入)
- Fix 5 属性正则边界锚定 (^|\s):data-src 等连字属性不卷入(\b 会误判 '-s'
  交界)——data-src 命中索引也不改写的用例钉死
- Fix 6 media 引用面只 join ready 资产;非 ready 残留引用单独查出记 issue、
  不进 content.json media 块(quarantined 翻转测试)

354/354(350+4);typecheck 四段、lint 全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 08:12:59 +08:00
parent 8f42c838c1
commit 5434afefff
3 changed files with 336 additions and 77 deletions
+105 -60
View File
@@ -6,12 +6,15 @@
// → succeeded;报告落 <artifactsRoot>/jobs/<jobId>/reports/build.json(temp+rename
// 原子写、**先于终态落行**——import 同款纪律)。
//
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key 复用既有 job 不重跑(含
// 失败 job:重试须换 key——与 import_jobs 同纪律);并发同 key 由唯一约束收敛(插入
// 撞 23505 → 重查复用)。默认幂等键 = site + **published 集合哈希** + 主题键@版本——
// published 集合不变(发布后导入产新 current 不动 published,ADR-010)→ 同键复用;
// 重新 publish 指针前移 → 集合变 → 新 job。显式 idempotencyKey 可强制新物化(测试
// 与操作者重跑通道)。
// 幂等:build_jobs.idempotency_key UNIQUE——重复提交同 key:**只有 succeeded 复用**
// (产物完整);failed/cancelled/stale 终态死行与 queued/snapshotting/building/
// validating 崩溃残留(非终态,旧行置 stale 宣判)都以 `<key>#a<attempt+1>` 后缀键
// 建新行自动重跑(审查 Fix 3——import"换 key 手动重试"纪律的构建域演进:单进程就地
// 推进下非终态滞留必为崩溃残留,自动恢复优于阻塞操作者);并发同 key 由唯一约束收敛
// (插入撞 23505 → 回查推进)。默认幂等键 = site + **published 集合哈希** + 主题键@
// 版本——published 集合不变(发布后导入产新 current 不动 published,ADR-010)→ 同键
// 复用;重新 publish 指针前移 → 集合变 → 新 job。显式 idempotencyKey 可强制新物化
// (测试与操作者重跑通道)。
//
// 版本钉死(build_input_snapshots 的可复现性义务):node/pnpm 运行时真实探测
// (process.versions / npm_config_user_agent);theme/astro 是 **Task 3/5 前占位**
@@ -85,7 +88,11 @@ export interface CreateBuildJobResult {
jobId: string
/** job 终态(或复用时既有状态) */
status: BuildJobStatus
/** true = 命中既有 job(幂等键复用,未重跑、未改行) */
/**
* true = 命中既有 **succeeded** job(幂等键复用,未重跑、未改行——唯一可复用终态)。
* false = 新建行:键空闲,或撞 failed/cancelled/stale(终态死行)/非终态崩溃残留
* (旧行置 stale 宣判)——两种死行都以 `<key>#a<attempt+1>` 后缀键建新行重跑。
*/
reused: boolean
siteKey: string
siteId: string
@@ -173,15 +180,21 @@ async function writeAtomically(path: string, content: string): Promise<void> {
}
}
/** 既有 job 的复用返回(不动行、不重跑;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
/** 既有 job 的复用查询(不动行;三哈希取 build_input_snapshots 行值——不可变权威);无则 null */
async function reuseExisting(
db: BuildDb,
idempotencyKey: string,
): Promise<Pick<CreateBuildJobResult, 'jobId' | 'status' | 'sourceHashes'> | null> {
): Promise<{
jobId: string
status: BuildJobStatus
attempt: number
sourceHashes: { content: string; route: string; asset: string }
} | null> {
const found = await db
.select({
id: buildJobs.id,
status: buildJobs.status,
attempt: buildJobs.attempt,
contentSnapshotHash: buildInputSnapshots.contentSnapshotHash,
routeSnapshotHash: buildInputSnapshots.routeSnapshotHash,
assetManifestHash: buildInputSnapshots.assetManifestHash,
@@ -197,6 +210,7 @@ async function reuseExisting(
return {
jobId: row.id,
status: row.status,
attempt: row.attempt,
sourceHashes: {
content: row.contentSnapshotHash ?? '',
route: row.routeSnapshotHash ?? '',
@@ -205,12 +219,17 @@ async function reuseExisting(
}
}
/** 后缀键派生上界(守卫异常并发/病态键空间——正常重试谱系个位数即收敛) */
const RETRY_KEY_BOUND = 100
/**
* 创建(并就地推进到底)一个 build job:
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued)→ snapshotting
* 解析(site → published 检查 → config 检查)→ 幂等建行(queued;**只有 succeeded
* 可复用**——终态死行/崩溃残留按阶段 3 注记的策略自动恢复)→ snapshotting
* (repeatable read 单事务:物化 + 六文件落盘 + build_input_snapshots 行)→
* validating(空——Task 6)→ 报告落盘 → succeeded。
* dist 产物与 build_artifacts 登记是 Task 5 职责(本任务 dist 尚空)。
* 快照后失败 = job 行 failed 终态 + 半途 input/ 目录清场 + 原样上抛。
*/
export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): Promise<CreateBuildJobResult> {
const startedAt = performance.now()
@@ -258,67 +277,84 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
)
}
// 阶段 3:幂等建 job(queued)——命中既有 key 直接复用返回(不重跑)
// 阶段 3:幂等建 job(queued)——**只有 succeeded 可复用**(产物完整且报告在)。
// 复用策略(审查 Fix 3 钉死):
// - succeeded → 原样复用返回(不重跑、不动行);
// - failed/cancelled/stale(终态)→ 不复用:以 `<key>#a<attempt+1>` 后缀键建新行
// (attempt 延续记重试谱系);
// - queued/snapshotting/building/validating(非终态 = 崩溃残留——本服务单进程就地
// 推进,库里滞留非终态即上个进程未走完)→ 旧行置 stale 宣判(finished_at 记宣判
// 时刻)+ 同上后缀新行;
// - 后缀键自身再撞非 succeeded 行 → 同策略继续递增(上界 RETRY_KEY_BOUND 守卫);
// - 并发插行撞 23505 → 回循环头重查(对端 succeeded 则复用,否则按上述策略推进)。
// 显式 idempotencyKey 同策略——操作者显式键撞死行同样自动换新行重跑。
const idempotencyKey = input.idempotencyKey ?? defaultBuildIdempotencyKey(site.key, publishedIds)
const existing = await reuseExisting(db, idempotencyKey)
if (existing !== null) {
const inputDir = join(artifactsRoot, 'jobs', existing.jobId, 'input')
return {
jobId: existing.jobId,
status: existing.status,
reused: true,
siteKey: site.key,
siteId: site.id,
inputDir,
reportPath: join(artifactsRoot, 'jobs', existing.jobId, 'reports', 'build.json'),
sourceHashes: existing.sourceHashes,
durationMs: performance.now() - startedAt,
}
}
let jobKey = idempotencyKey
let attempt = 1
let jobId: string | undefined
try {
const inserted = await db
.insert(buildJobs)
.values({ siteId: site.id, status: 'queued', idempotencyKey })
.returning({ id: buildJobs.id })
jobId = inserted[0]!.id
} catch (err) {
// 并发同 key:唯一约束收敛——重查复用(插入撞 23505 说明行已存在,重查必命中)
if (isUniqueViolation(err)) {
const raced = await reuseExisting(db, idempotencyKey)
if (raced !== null) {
return {
jobId: raced.jobId,
status: raced.status,
reused: true,
siteKey: site.key,
siteId: site.id,
inputDir: join(artifactsRoot, 'jobs', raced.jobId, 'input'),
reportPath: join(artifactsRoot, 'jobs', raced.jobId, 'reports', 'build.json'),
sourceHashes: raced.sourceHashes,
durationMs: performance.now() - startedAt,
}
for (let round = 0; ; round++) {
if (round > RETRY_KEY_BOUND) {
throw new Error(`幂等键重试派生超界(${idempotencyKey}#a… > ${RETRY_KEY_BOUND})——疑似异常并发`)
}
const found = await reuseExisting(db, jobKey)
if (found !== null && found.status === 'succeeded') {
return {
jobId: found.jobId,
status: found.status,
reused: true,
siteKey: site.key,
siteId: site.id,
inputDir: join(artifactsRoot, 'jobs', found.jobId, 'input'),
reportPath: join(artifactsRoot, 'jobs', found.jobId, 'reports', 'build.json'),
sourceHashes: found.sourceHashes,
durationMs: performance.now() - startedAt,
}
}
throw err
if (found !== null) {
if (found.status !== 'failed' && found.status !== 'cancelled' && found.status !== 'stale') {
await db
.update(buildJobs)
.set({ status: 'stale', finishedAt: new Date() })
.where(eq(buildJobs.id, found.jobId))
}
attempt = found.attempt + 1
jobKey = `${idempotencyKey}#a${attempt}`
continue
}
try {
const inserted = await db
.insert(buildJobs)
.values({ siteId: site.id, status: 'queued', idempotencyKey: jobKey })
.returning({ id: buildJobs.id })
jobId = inserted[0]!.id
break
} catch (err) {
// 并发同 key:唯一约束收敛 → 回循环头重查(23505 = 行已存在)
if (isUniqueViolation(err)) continue
throw err
}
}
const inputDir = join(artifactsRoot, 'jobs', jobId, 'input')
const reportDir = join(artifactsRoot, 'jobs', jobId, 'reports')
const reportPath = join(reportDir, 'build.json')
input.onStatus?.('queued', jobId)
// 失败收口:job 行保留(failed 终态 + error 面)后原样上抛——镜像 import 纪律
// 失败收口:job 行保留(failed 终态 + error 面 + input/ 目录清场)后原样上抛——镜像 import 纪律
try {
// 阶段 4:snapshotting(attempt=1、本地 worker 就地推进)
// 阶段 4:snapshotting(attempt 延续重试谱系:新建 1 / 撞死行重跑 prev+1;本地 worker 就地推进)
await db
.update(buildJobs)
.set({ status: 'snapshotting', startedAt: new Date(), attempt: 1, workerId: 'local' })
.set({ status: 'snapshotting', startedAt: new Date(), attempt, workerId: 'local' })
.where(eq(buildJobs.id, jobId))
input.onStatus?.('snapshotting', jobId)
// 阶段 5:repeatable read 单事务——一致性读取(published 集/路由/资产同一时点)+
// 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)
// 六文件落盘 + build_input_snapshots 行(读取面与登记面原子:行在即文件内容可复算)。
// 版本/环境探测在事务外单次完成(审查 Fix 4:git rev-parse 是进程派生,不进事务窗口)
const buildTimestamp = new Date()
const sourceCommit = detectSourceCommit()
const pnpmVersion = detectPnpmVersion()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const mat = await db.transaction(
async (tx) => {
const m = await materializeRenderSnapshot(tx, { siteId: site.id, generatedAt: buildTimestamp.toISOString() })
@@ -337,12 +373,12 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
themeVersion: THEME_VERSION,
themeSourceHash: THEME_SOURCE_HASH_PLACEHOLDER,
dependencyLockHash: DEPENDENCY_LOCK_HASH_PLACEHOLDER,
sourceCommit: detectSourceCommit(),
sourceCommit,
nodeVersion: process.versions.node,
pnpmVersion: detectPnpmVersion(),
pnpmVersion,
astroVersion: ASTRO_VERSION_PLACEHOLDER,
locale: site.defaultLocale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
timezone,
buildTimestamp,
manifestPath: join(inputDir, 'render-manifest.json'),
})
@@ -362,7 +398,7 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
status: 'succeeded',
siteKey: site.key,
siteId: site.id,
idempotencyKey,
idempotencyKey: jobKey,
theme: { key: THEME_KEY, version: THEME_VERSION },
themePlaceholders: {
themeSourceHash: true,
@@ -371,10 +407,10 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
},
buildEnv: {
node: process.versions.node,
pnpm: detectPnpmVersion(),
sourceCommit: detectSourceCommit(),
pnpm: pnpmVersion,
sourceCommit,
locale: site.defaultLocale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
timezone,
},
snapshot: {
siteConfigRevisionId: mat.siteConfigRevisionId,
@@ -427,6 +463,15 @@ export async function createBuildJob(db: BuildDb, input: CreateBuildJobInput): P
.update(buildJobs)
.set({ status: 'failed', finishedAt: new Date(), errorCode: code, errorSummary: `[build] ${summary}` })
.where(eq(buildJobs.id, jobId))
// 失败清场(审查 Fix 3):半途落盘的 input/ 目录移除——failed 终态无产物语义,
// 不留"行说 failed 而目录半存"的歧义现场。快照行若已插入则由不可变触发器保留为
// 审计记录(manifest_path 悬空可接受:job 已 failed,无下游消费方)。清场失败
// 不掩盖原始错误(残留目录由操作者处置)。
try {
await rm(inputDir, { recursive: true, force: true })
} catch {
// best-effort:保留原错误上抛
}
throw err
}
}
+54 -10
View File
@@ -15,8 +15,9 @@
// data:/纯片段/相对形态无路径可比对,原样越过不计(与 media-writer 的 data: 口径一致)。
//
// canonical JSON 确定性:六文件全部以 canonicalJson(键递归字典序、无空白)+ 换行落盘
// ——文件字节即哈希输入(content/route/asset 三哈希 = 各自 canonicalJson 的 sha256),
// 同输入(含 generatedAt 时点参数)两次物化字节一致。集合排序在 JS 侧钉死(UTF-16
// ——文件字节(含尾换行)即哈希输入(content/route/asset 三哈希 = 各自**文件字节**的
// sha256——哈希链与落盘文件互证),同输入(含 generatedAt 时点参数)两次物化字节一致。
// 集合排序在 JS 侧钉死(UTF-16
// 序,不依赖 PG collation):内容按 (typeKey, slug, locale, contentId)、分类按 (key,
// objectType)、term 按 (sourceId, slug, id)、路由按 (routePath, queryKey nulls-first,
// host)、资产按 publicPath。
@@ -29,7 +30,7 @@ import { createHash } from 'node:crypto'
import { existsSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { and, desc, eq, inArray, isNotNull } from 'drizzle-orm'
import { and, desc, eq, inArray, isNotNull, ne } from 'drizzle-orm'
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
import {
ADAPTER_VERSION,
@@ -121,12 +122,15 @@ export interface RewriteStats {
/**
* bodyHtml 的 src/href URL 重写(正则提取——清洗信任链上游已保证属性形态规整;
* 门禁期的 HTML 解析复用清洗管线是 Task 6 职责,此处不引入解析器依赖)。
* 属性名边界锚定(审查 Fix 5):前缀必须是空白或串首——`data-src`/`data-href`
* 等连字属性不匹配不卷入(\b 会把 data-src 的 '-s' 交界误判为词边界;懒加载
* 属性的改写是渲染域决策,不是快照域义务)。
* 只替换属性值本身,引号/空白原样保留;命中值与原值相等时计数但零改写。
*/
export function rewriteBodyHtml(html: string, index: Map<string, string>): { html: string; stats: RewriteStats } {
const stats: RewriteStats = { rewritten: 0, unresolved: 0 }
const ATTR_RE = /\b(src|href)\s*=\s*(["'])([^"']*)\2/gi
const out = html.replace(ATTR_RE, (full, _attr: string, quote: string, value: string) => {
const ATTR_RE = /(^|\s)(src|href)\s*=\s*(["'])([^"']*)\3/gi
const out = html.replace(ATTR_RE, (full, _prefix: string, _attr: string, quote: string, value: string) => {
const path = comparablePathOf(value)
if (path === null) return full
const hit = index.get(path)
@@ -184,9 +188,9 @@ function cmpNull(a: string | null, b: string | null): number {
return cmp(a, b)
}
/** sha256 of canonicalJson(三哈希的单一算法来源) */
/** 三哈希的单一算法来源:sha256(文件字节)——canonicalJson + 尾换行后散列(与落盘文件互证) */
function hashOf(payload: unknown): string {
return createHash('sha256').update(canonicalJson(payload)).digest('hex')
return createHash('sha256').update(`${canonicalJson(payload)}\n`).digest('hex')
}
/** 仓库存储根的 incoming 媒体兜底目录(真实数据:storage/incoming/<目录>/<扁平 GUID 文件>) */
@@ -258,7 +262,11 @@ export async function materializeRenderSnapshot(
assetRows.sort((a, b) => cmp(a.publicPath, b.publicPath))
const mediaIndex = buildMediaPathIndex(assetRows)
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)
// published 修订的 media_usages 引用面(fieldPath → public_path;content.json 的 media 块)。
// 确定性(审查 Fix 2):fieldPath/publicPath 双键 orderBy 钉死 first-wins 的"首"——
// 同 fieldPath 多资产引用(PK 是 资产×修订×fieldPath,跨资产可重)时取字典序首位,
// 其余丢弃并记 issue(§7.6 禁静默);只 join ready 资产(media 块只呈现可服务引用,
// 审查 Fix 6)——非 ready 资产的残留引用单独查出版 issue,不进块。
const publishedIds = published.map((r) => r.revisionId!)
const usageMap = new Map<string, Map<string, string>>()
if (publishedIds.length > 0) {
@@ -270,14 +278,50 @@ export async function materializeRenderSnapshot(
})
.from(mediaUsages)
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
.where(and(eq(mediaUsages.siteId, input.siteId), inArray(mediaUsages.contentRevisionId, publishedIds)))
.where(
and(
eq(mediaUsages.siteId, input.siteId),
inArray(mediaUsages.contentRevisionId, publishedIds),
eq(mediaAssets.status, 'ready'),
),
)
.orderBy(mediaUsages.fieldPath, mediaAssets.publicPath)
for (const u of usages) {
let byField = usageMap.get(u.contentRevisionId)
if (byField === undefined) {
byField = new Map()
usageMap.set(u.contentRevisionId, byField)
}
byField.set(u.fieldPath, u.publicPath)
const first = byField.get(u.fieldPath)
if (first === undefined) {
byField.set(u.fieldPath, u.publicPath)
} else if (first !== u.publicPath) {
issues.push(
`同 fieldPath 多资产引用(revision=${u.contentRevisionId} fieldPath=${u.fieldPath}):first-wins 取 ${first},丢弃 ${u.publicPath}`,
)
}
}
// 非 ready 资产的残留引用(登记后状态翻转等数据面事实):不进 media 块,如实记 issue
const nonReady = await db
.select({
contentRevisionId: mediaUsages.contentRevisionId,
fieldPath: mediaUsages.fieldPath,
publicPath: mediaAssets.publicPath,
status: mediaAssets.status,
})
.from(mediaUsages)
.innerJoin(mediaAssets, eq(mediaUsages.mediaAssetId, mediaAssets.id))
.where(
and(
eq(mediaUsages.siteId, input.siteId),
inArray(mediaUsages.contentRevisionId, publishedIds),
ne(mediaAssets.status, 'ready'),
),
)
for (const u of nonReady) {
issues.push(
`media 引用非 ready 资产(revision=${u.contentRevisionId} fieldPath=${u.fieldPath} public_path=${u.publicPath} status=${u.status})——该引用不进 content.json media 块`,
)
}
}