合并最新master到设计代理分支
Project CI / Repository checks (pull_request) Failing after 4m24s
Project CI / Frontend tests (pull_request) Successful in 6m0s
Project CI / Backend tests (pull_request) Successful in 9m12s
Project CI / Native shell tests (pull_request) Successful in 16m21s

同步 master 的退款 outbox、认证投影与外部生成历史清理改动

保留 GDD 审批修复及双方决策记录
This commit is contained in:
2026-08-28 03:00:51 +00:00
78 changed files with 8665 additions and 1503 deletions
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -821,6 +821,31 @@ const checks = [
reason:
'生产冷备份 service 必须用 node -- 分隔脚本参数,避免 Node 22 抢占业务 --env-file。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'Environment=NODE_OPTIONS=--max-old-space-size=768',
reason:
'备份 Node 进程必须设置独立 heap 上限,避免目录扫描异常拖垮 release 主机。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes:
'Environment=GENARRATIVE_DATABASE_BACKUP_STOP_MARKER=/var/lib/genarrative/database-backups/.spacetimedb-stopped',
reason:
'备份停库 marker 必须固定在受保护的 release work-dir,供 OOM 后 systemd 兜底恢复服务。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'MemoryMax=1G',
reason:
'备份 service 必须设置 systemd 内存硬上限,避免异常进程消耗整机内存。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: 'ExecStopPost=/bin/sh -c',
reason:
'备份主进程被 OOM kill 后必须由 systemd 兜底恢复停掉的 SpacetimeDB、API、worker 和 controller。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
excludes: '--storage-format files',
@@ -941,10 +966,9 @@ const checks = [
},
{
file: 'jenkins/Jenkinsfile.production-server-provision',
excludes:
"params.DEPLOY_TARGET == 'release' && databaseBackupProfile == 'files-history'",
includes: 'release 仅允许 archive-full;files-history',
reason:
'release 必须能在显式选择 profile 且 baseline 预检通过后启用 files-history。',
'release 必须拒绝 files-history,避免逐文件 catalog 扫描再次触发生产内存峰值。',
},
{
file: 'scripts/database-backup-to-oss.mjs',
@@ -954,7 +978,7 @@ const checks = [
{
file: 'scripts/database-backup-to-oss.mjs',
includes:
'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter})',
'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath})',
reason: '生产冷备份打包失败时也必须恢复 SpacetimeDB 及依赖服务。',
},
{
File diff suppressed because it is too large Load Diff
+4
View File
@@ -78,6 +78,10 @@ validate_database_backup_profile() {
exit 1
;;
esac
if [[ "${DEPLOY_TARGET}" == "release" && "${DATABASE_BACKUP_PROFILE}" == "files-history" ]]; then
echo "[server-provision] release 仅允许 archive-full;files-history 会把整棵历史目录加载到 Node 内存,需先完成流式 catalog 改造后才能重新启用。" >&2
exit 1
fi
if [[ ! "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" =~ ^/var/lib/genarrative/database-backups/[A-Za-z0-9._/-]+$ || "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" == *..* ]]; then
echo "[server-provision] DATABASE_BACKUP_FILES_HISTORY_WORK_DIR 必须是 /var/lib/genarrative/database-backups/ 下不含连续点号的绝对路径,当前值: ${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}" >&2
exit 1
@@ -8,23 +8,29 @@ import {
} from './spacetime-migration-common.mjs';
const MAX_BATCH_SIZE = 25;
const DEFAULT_RETENTION_DAYS = 30;
const MICROS_PER_DAY = 86_400_000_000;
function usage() {
return `用法:
node scripts/spacetime-maintain-external-generation-jobs.mjs --database <name> [选项]
默认只 dry-run 一批历史终态任务 payload 压缩,不修改数据库。
使用 --prune-history 时改为清理已确认通知且超过保留期的历史任务、摘要与事件。
公共选项:
--database <name> 目标数据库(必填,也可用 GENARRATIVE_SPACETIME_DATABASE)
--server <name-or-url> spacetime CLI server 名或 URL
--server-url <url> 显式 server URL
--limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10
--limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10
--cursor-job-id <jobId> 从上一批 next_cursor_job_id 继续
--apply 执行写入;省略时始终 dry-run
--backfill-summaries 改为回填轻量摘要投影
--prune-history 改为清理已确认通知的终态历史
--owner-user-id <userId> 仅摘要回填可选,限定 owner
--completed-before-micros <n> 仅 payload 压缩可选,限定终态完成时间
--source-module <module> 仅历史清理可选,默认 editor-canvas
--retention-days <n> 仅历史清理可选,默认 ${DEFAULT_RETENTION_DAYS} 天
--completed-before-micros <n> 限定终态完成时间;历史清理默认按 retention-days 计算
--help 显示帮助
必须使用已授权 migration operator 的 spacetime CLI 登录态。脚本每次只处理一批;
@@ -40,6 +46,9 @@ function parseOptions(argv) {
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
limit: 10,
ownerUserId: '',
pruneHistory: false,
retentionDays: DEFAULT_RETENTION_DAYS,
sourceModule: 'editor-canvas',
passthrough: [],
server: process.env.GENARRATIVE_SPACETIME_SERVER || '',
serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '',
@@ -82,6 +91,15 @@ function parseOptions(argv) {
options.apply = true;
} else if (arg === '--backfill-summaries') {
options.backfillSummaries = true;
} else if (arg === '--prune-history') {
options.pruneHistory = true;
} else if (arg === '--source-module') {
options.sourceModule = readValue(arg).trim();
if (!options.sourceModule) {
throw new Error('--source-module 不能为空。');
}
} else if (arg === '--retention-days') {
options.retentionDays = parsePositiveInteger(readValue(arg), arg);
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
@@ -95,12 +113,49 @@ function parseOptions(argv) {
if (options.ownerUserId && !options.backfillSummaries) {
throw new Error('--owner-user-id 只能与 --backfill-summaries 一起使用。');
}
if (options.backfillSummaries && options.pruneHistory) {
throw new Error('--backfill-summaries 与 --prune-history 不能同时使用。');
}
if (options.sourceModule !== 'editor-canvas' && !options.pruneHistory) {
throw new Error('--source-module 只能与 --prune-history 一起使用。');
}
if (
options.retentionDays !== DEFAULT_RETENTION_DAYS &&
!options.pruneHistory
) {
throw new Error('--retention-days 只能与 --prune-history 一起使用。');
}
if (options.completedBeforeMicros !== null && options.backfillSummaries) {
throw new Error('--completed-before-micros 不能用于摘要回填。');
}
if (
options.completedBeforeMicros !== null &&
options.pruneHistory &&
options.retentionDays !== DEFAULT_RETENTION_DAYS
) {
throw new Error(
'--completed-before-micros 与 --retention-days 不能同时使用。',
);
}
return options;
}
function resolveRetentionCutoffMicros(options) {
if (!options.pruneHistory) {
return options.completedBeforeMicros;
}
if (options.completedBeforeMicros !== null) {
return options.completedBeforeMicros;
}
const cutoff = Date.now() * 1000 - options.retentionDays * MICROS_PER_DAY;
if (!Number.isSafeInteger(cutoff)) {
throw new Error(
'--retention-days 计算出的 completed_before_micros 超出安全整数范围。',
);
}
return cutoff;
}
try {
const options = parseOptions(process.argv.slice(2));
if (options.help) {
@@ -113,24 +168,36 @@ try {
);
}
const procedureName = options.backfillSummaries
? 'backfill_external_generation_job_summaries_and_return'
: 'compact_external_generation_job_payloads_and_return';
const input = options.backfillSummaries
const completedBeforeMicros = resolveRetentionCutoffMicros(options);
const procedureName = options.pruneHistory
? 'prune_external_generation_job_history_and_return'
: options.backfillSummaries
? 'backfill_external_generation_job_summaries_and_return'
: 'compact_external_generation_job_payloads_and_return';
const input = options.pruneHistory
? {
owner_user_id: encodeSpacetimeCliOption(options.ownerUserId || null),
source_module: options.sourceModule,
limit: options.limit,
cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null),
completed_before_micros: completedBeforeMicros,
dry_run: !options.apply,
}
: {
dry_run: !options.apply,
limit: options.limit,
cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null),
completed_before_micros: encodeSpacetimeCliOption(
options.completedBeforeMicros,
),
};
: options.backfillSummaries
? {
owner_user_id: encodeSpacetimeCliOption(options.ownerUserId || null),
limit: options.limit,
cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null),
dry_run: !options.apply,
}
: {
dry_run: !options.apply,
limit: options.limit,
cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null),
completed_before_micros: encodeSpacetimeCliOption(
completedBeforeMicros,
),
};
const result = await callSpacetimeProcedureViaCli(
options,
procedureName,
@@ -138,10 +205,29 @@ try {
);
ensureProcedureOk(result);
console.log(JSON.stringify({ procedure: procedureName, ...result }, null, 2));
const pendingApplyCount = options.backfillSummaries
? Number(result.selected_count ?? 0)
: Number(result.matched_count ?? 0);
console.log(
JSON.stringify(
{
procedure: procedureName,
...(options.pruneHistory
? {
source_module: options.sourceModule,
completed_before_micros: completedBeforeMicros,
...(options.completedBeforeMicros === null
? { retention_days: options.retentionDays }
: {}),
}
: {}),
...result,
},
null,
2,
),
);
const pendingApplyCount =
options.pruneHistory || options.backfillSummaries
? Number(result.selected_count ?? 0)
: Number(result.matched_count ?? 0);
if (result.has_more && options.apply) {
console.log(
`仍有后续批次;下一次追加 --cursor-job-id ${result.next_cursor_job_id ?? '<missing>'}。`,
@@ -150,8 +236,11 @@ try {
const currentCursor = options.cursorJobId
? `保留 --cursor-job-id ${options.cursorJobId}`
: '仍从首批开始';
const cutoffHint = options.pruneHistory
? `并固定 --completed-before-micros ${completedBeforeMicros}`
: '';
console.log(
`当前仅 dry-run;请${currentCursor}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`,
`当前仅 dry-run;请${currentCursor}${cutoffHint}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`,
);
}
} catch (error) {
+59 -12
View File
@@ -9,11 +9,13 @@ export function parseArgs(argv) {
'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE',
),
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '',
bootstrapSecret:
process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '',
bootstrapSecretFile:
process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_FILE || '',
includeTables: [],
operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '',
operatorIdentity:
process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '',
passthrough: [],
note: '',
server: process.env.GENARRATIVE_SPACETIME_SERVER || '',
@@ -142,7 +144,9 @@ export function buildSpacetimeCallArgs(options, procedureName, input) {
export async function callSpacetimeProcedure(options, procedureName, input) {
if (!options.database) {
throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。');
throw new Error(
'必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。',
);
}
validateSpacetimeDatabaseName(options.database);
@@ -196,7 +200,9 @@ export async function createSpacetimeWebIdentity(options) {
const text = await response.text();
if (!response.ok) {
throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`);
throw new Error(
`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`,
);
}
let payload;
@@ -209,16 +215,25 @@ export async function createSpacetimeWebIdentity(options) {
}
const identity =
payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex;
payload.identity ??
payload.Identity ??
payload.identity_hex ??
payload.identityHex;
const token = payload.token ?? payload.Token;
if (typeof identity !== 'string' || typeof token !== 'string') {
throw new Error(`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`);
throw new Error(
`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`,
);
}
return { identity, token };
}
export async function callSpacetimeProcedureAuto(options, procedureName, input) {
export async function callSpacetimeProcedureAuto(
options,
procedureName,
input,
) {
if (options.useHttp) {
return callSpacetimeProcedure(options, procedureName, input);
}
@@ -226,7 +241,11 @@ export async function callSpacetimeProcedureAuto(options, procedureName, input)
return callSpacetimeProcedureViaCli(options, procedureName, input);
}
export async function callSpacetimeProcedureViaCli(options, procedureName, input) {
export async function callSpacetimeProcedureViaCli(
options,
procedureName,
input,
) {
const args = buildSpacetimeCallArgs(options, procedureName, input);
const output = await runSpacetimeCli(args);
return parseProcedureResult(output, procedureName);
@@ -335,7 +354,8 @@ function normalizeSatsProduct(value, procedureName) {
}
if (
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
procedureName ===
'normalize_editor_character_animation_metadata_and_return' &&
value.length === 19
) {
return {
@@ -427,6 +447,24 @@ function normalizeSatsProduct(value, procedureName) {
};
}
if (
procedureName === 'prune_external_generation_job_history_and_return' &&
value.length === 10
) {
return {
ok: normalizeSatsValue(value[0]),
dry_run: normalizeSatsValue(value[1]),
scanned_count: normalizeSatsValue(value[2]),
selected_count: normalizeSatsValue(value[3]),
deleted_job_count: normalizeSatsValue(value[4]),
deleted_summary_count: normalizeSatsValue(value[5]),
deleted_event_count: normalizeSatsValue(value[6]),
next_cursor_job_id: normalizeSatsOption(value[7]),
has_more: normalizeSatsValue(value[8]),
error_message: normalizeSatsOption(value[9]),
};
}
if (value.length === 3) {
return {
ok: normalizeSatsValue(value[0]),
@@ -497,7 +535,10 @@ function normalizeSatsValue(value) {
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]),
Object.entries(value).map(([key, entry]) => [
key,
normalizeSatsValue(entry),
]),
);
}
@@ -581,7 +622,9 @@ export function resolveServerUrl(options) {
return 'http://127.0.0.1:3101';
}
throw new Error(`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`);
throw new Error(
`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`,
);
}
function resolveCliServer(options) {
@@ -635,7 +678,11 @@ function runSpacetimeCli(args) {
return;
}
if (code !== 0) {
reject(new Error(`spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`));
reject(
new Error(
`spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`,
),
);
return;
}
@@ -223,4 +223,35 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
expect(objectResult.batch_sha256).toBe('d'.repeat(64));
expect(objectResult).not.toHaveProperty('batch_sha_256');
});
it('normalizes external generation history prune tuple results', () => {
const result = parseProcedureResult(
JSON.stringify([
true,
false,
25,
2,
2,
2,
10,
[0, 'job-25'],
true,
[0, '清理失败'],
]),
'prune_external_generation_job_history_and_return',
);
expect(result).toEqual({
ok: true,
dry_run: false,
scanned_count: 25,
selected_count: 2,
deleted_job_count: 2,
deleted_summary_count: 2,
deleted_event_count: 10,
next_cursor_job_id: 'job-25',
has_more: true,
error_message: '清理失败',
});
});
});