Preview: Runner-Verfolger verdraengt sauber, R15-Grenzen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent 4258b5b9f1
commit d788dba937
2 files changed
+126 -15

No files matched your search

+29 -15
View File
@@ -6,7 +6,7 @@ import { config } from '../config.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { withLocks } from './keyedLock.js'
import { giteaFetch, splitRepo } from './giteaClient.js'
import { envKey, decryptEnv } from './previewEnv.js'
import { envKey, decryptEnv, maskLog } from './previewEnv.js'
import { saveRunLog } from './previewLogs.js'
import * as runnerClient from './previewRunnerClient.js'
@@ -430,7 +430,7 @@ export async function syncPreviewTlsOnStartup({
// ---- Preview-Runner (PREVIEW_RUNNER=on) ----
// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar)
export const runnerTiming = { pollMs: 2000, maxMs: 16 * 60 * 1000 }
export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000 }
// Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14)
const latestRun = new Map()
const followers = new Set()
@@ -484,24 +484,31 @@ async function resolveSha(repoFullName, branch, sha) {
return head
}
async function followRun({ repoFullName, project, runId, subdomain, job, env, warning }) {
async function followRun({ repoFullName, project, token, runId, subdomain, job, env, warning }) {
const deadline = Date.now() + runnerTiming.maxMs
const current = () => latestRun.get(repoFullName) === runId
const current = () => latestRun.get(repoFullName) === token
let run = null
let failure = null
let unreachableSince = 0
try {
while (current()) {
await new Promise((r) => setTimeout(r, runnerTiming.pollMs))
if (!current()) return
try {
run = await runnerClient.getRun(runId)
unreachableSince = 0
} catch (err) {
run = null
if (err.status === 404) { failure = 'Der Preview-Lauf ist beim Runner nicht mehr bekannt'; break }
console.error('[previewDeploy] Runner-Abfrage fehlgeschlagen:', err.message)
run = null
unreachableSince ||= Date.now()
if (Date.now() - unreachableSince > runnerTiming.unreachableMs) {
failure = 'Preview-Runner nicht erreichbar'
break
}
}
if (run?.state === 'done' || run?.state === 'failed') break
if (Date.now() > deadline) { failure = 'Zeitüberschreitung beim Preview-Build'; break }
if (Date.now() > deadline) { failure = 'Status unbekannt – bitte neu bauen'; break }
}
if (!current()) return
@@ -529,7 +536,7 @@ async function followRun({ repoFullName, project, runId, subdomain, job, env, wa
} else {
await patchProject(repoFullName, project, {
previewState: 'failed',
previewError: failure || run?.error || 'Preview-Build fehlgeschlagen',
previewError: maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env),
})
}
if (!current()) return
@@ -539,19 +546,25 @@ async function followRun({ repoFullName, project, runId, subdomain, job, env, wa
} catch (err) {
console.error(`[previewDeploy] Verfolger ${runId} abgebrochen:`, err.message)
} finally {
if (latestRun.get(repoFullName) === runId && run && (run.state === 'done' || run.state === 'failed')) {
latestRun.delete(repoFullName)
}
if (latestRun.get(repoFullName) === token) latestRun.delete(repoFullName)
}
}
async function runDeployViaRunner(repoFullName, branch, previewConfig, { project, sha } = {}) {
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
// Platz fuer diesen Aufruf sofort belegen (vor jedem await): aeltere Verfolger scheiden aus
const token = Symbol('run')
latestRun.set(repoFullName, token)
const current = () => latestRun.get(repoFullName) === token
const release = () => { if (current()) latestRun.delete(repoFullName) }
const doc = project || (await findProject(repoFullName))
const mode = doc?.previewMode || 'auto'
if (mode === 'off') {
await patchProject(repoFullName, doc, { previewState: 'off' })
try {
if (current()) await patchProject(repoFullName, doc, { previewState: 'off' })
} finally { release() }
return { deployed: false, skipped: true, reason: 'Preview ist ausgeschaltet', subdomain }
}
@@ -576,15 +589,16 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
env,
}
const { runId } = await runnerClient.submitBuild(job)
latestRun.set(repoFullName, runId)
await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning })
const follower = followRun({ repoFullName, project: doc, runId, subdomain, job, env, warning })
if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning })
const follower = followRun({ repoFullName, project: doc, token, runId, subdomain, job, env, warning })
followers.add(follower)
follower.finally(() => followers.delete(follower))
return { deployed: false, queued: true, runId, subdomain }
} catch (err) {
console.error(`[previewDeploy] Runner-Auftrag fuer ${repoFullName} fehlgeschlagen:`, err.message)
await patchProject(repoFullName, doc, { previewState: 'failed', previewError: err.message })
try {
if (current()) await patchProject(repoFullName, doc, { previewState: 'failed', previewError: maskLog(err.message, env) })
} finally { release() }
return { deployed: false, error: err.message, subdomain }
}
}
+97
View File
@@ -256,3 +256,100 @@ test('Runner ausgelastet (503): failed mit Meldung, kein Wurf', async () => {
assert.equal(r.error, 'Server ausgelastet')
assert.equal(aw.get('websiteProjects', id).previewState, 'failed')
})
test('Modus off während ein Lauf läuft: Zustand bleibt off, kein TLS-Sync vom alten Verfolger', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const r1 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const off = await updateDocument('websiteProjects', id, { previewMode: 'off' })
await pd.runDeploy(repo, 'main', cfg(repo), { project: off, sha: SHA })
rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: SHA, log: ['alt'], error: null, result: 'static' })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'off')
assert.equal((await logs.listRunLogs(id)).length, 0)
})
test('gleichzeitige Aufrufe: der neueste gewinnt', async () => {
const { id, repo, doc } = await project()
rs.script = (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: [job.sha], error: null, result: 'static' })
const sha2 = 'e'.repeat(40)
const [, r2] = await Promise.all([
pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }),
pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: sha2 }),
])
await pd.waitForRunFollowers()
assert.ok(r2.queued)
assert.equal(aw.get('websiteProjects', id).previewBuiltSha, sha2)
})
test('fehlgeschlagener neuer Auftrag verdrängt den alten Verfolger', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const r1 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
rs.submitError = 503
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static' }, sha: SHA, log: [], error: null, result: 'static' })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewState, 'failed')
})
test('R15: folgt queued/running über die alte 16-Minuten-Grenze, Runner-Ausfall lange toleriert', async () => {
const { id, repo, doc } = await project()
let polls = 0
rs.script = () => ({ state: 'queued', detected: null, sha: SHA, log: [], error: null, result: null })
const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
const orig = rs.runs.get(r.runId)
Object.defineProperty(orig, 'state', { get() { polls++; return polls < 15 ? 'queued' : 'done' }, enumerable: true })
await pd.waitForRunFollowers()
assert.ok(polls >= 15)
assert.equal(aw.get('websiteProjects', id).previewState, 'online')
})
test('R15: Gesamtzeit überschritten => failed mit "Status unbekannt"', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const saved = pd.runnerTiming.maxMs
pd.runnerTiming.maxMs = 60
try {
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
} finally { pd.runnerTiming.maxMs = saved }
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'failed')
assert.equal(d.previewError, 'Status unbekannt – bitte neu bauen')
})
test('R15: Runner dauerhaft unerreichbar (> unreachableMs) => failed', async () => {
const { id, repo, doc } = await project()
rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null })
const { config } = await import('../server/config.js')
const savedUrl = config.previewRunner.url
const saved = pd.runnerTiming.unreachableMs
pd.runnerTiming.unreachableMs = 50
try {
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
config.previewRunner.url = 'http://127.0.0.1:1'
await pd.waitForRunFollowers()
} finally { config.previewRunner.url = savedUrl; pd.runnerTiming.unreachableMs = saved }
const d = aw.get('websiteProjects', id)
assert.equal(d.previewState, 'failed')
assert.match(d.previewError, /nicht erreichbar/)
})
test('Runner-Fehlertext wird maskiert', async () => {
rs.script = done({ state: 'failed', result: null, error: 'Fehler mit supergeheim99', log: [] })
const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ K: 'supergeheim99' }, key) })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
assert.equal(aw.get('websiteProjects', id).previewError, 'Fehler mit •••')
})
test('dev-Ergebnis: PUT /dev/<sub> mit vollständigem Auftrag inkl. Variablen', async () => {
rs.script = done({ result: 'dev', detected: { kind: 'dev', pm: 'npm' } })
const { repo, doc } = await project({ previewEnv: envLib.encryptEnv({ A: 'wert1234' }, key), previewMode: 'dev' })
await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA })
await pd.waitForRunFollowers()
const put = rs.calls.find((c) => c.method === 'PUT')
assert.deepEqual(put.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'dev', env: { A: 'wert1234' } })
})