修复外部生成任务大载荷导致的内存膨胀
阻止图片编辑器将 Data URL 和超限 JSON 写入生成任务,复用 objectKey 等轻量引用 新增外部生成任务摘要投影与分批 payload 压缩回填流程,避免正式列表读取大字段 收紧 SpacetimeDB 发布为永不删数据并移除 Jenkins 普通清库入口,补齐维护窗口门禁 更新生成绑定、运维文档和回归测试,并完成生产数据副本迁移与压缩验证
This commit is contained in:
@@ -546,6 +546,7 @@ chmod +x \
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET_DIR}/scripts/database-export.mjs" "数据库导出脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||
@@ -584,7 +585,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- 迁移引导密钥不进入发布包;WASM 仅嵌入 SHA-256 摘要,原始 secret 由 Jenkins Secret File 在 Stdb publish 时受保护地交付。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、外部生成任务 payload 压缩 / 摘要回填脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/deploy/production-api-deploy.sh\`:API Deploy 执行入口;同目录的 \`maintenance-on.sh\` / \`maintenance-off.sh\` 必须来自同一发布包。
|
||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||
|
||||
|
||||
@@ -135,12 +135,28 @@ const checks = [
|
||||
reason:
|
||||
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: 'stop_runtime_services_for_rollout_gate',
|
||||
reason:
|
||||
'Stdb 与 API 之间需要人工维护时,必须停止旧 API/controller/worker,不能只保留网关维护文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '按参数保持维护模式和旧运行时服务停止状态',
|
||||
reason: '受控维护发布成功后不得自动重启旧运行时或退出维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
reason:
|
||||
'生产 API/worker env 必须统一指向 Stdb publish 写入的受保护 bootstrap secret 文件。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: '继承已有维护模式;部署失败时不得误退出上游维护窗口',
|
||||
reason: 'API deploy 必须区分自己打开的维护模式与 Stdb gate 继承的维护模式。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/jenkins-server-provision.sh',
|
||||
includes: 'ensure_runtime_bootstrap_secret_file_env',
|
||||
@@ -211,6 +227,26 @@ const checks = [
|
||||
includes: 'Secret File 与构建 WASM 的 bootstrap secret 摘要不一致',
|
||||
reason: '生产 Stdb publish 必须阻断 Secret File 与构建 WASM 摘要不一致。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--delete-data=never',
|
||||
reason: '生产 Stdb publish 必须显式禁止 schema 冲突时删除数据。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '--yes=migrate,break-clients',
|
||||
reason: '生产 Stdb publish 只能跳过迁移与客户端断开确认,不能使用等价 delete-data 的裸 --yes。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
excludes: '--clear-database',
|
||||
reason: '生产 Stdb publish 普通入口不得保留清库参数。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
excludes: 'CLEAR_DATABASE',
|
||||
reason: 'Stdb 发布流水线不得向普通构建参数暴露清库开关。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-stdb-publish.sh',
|
||||
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
|
||||
@@ -290,6 +326,43 @@ const checks = [
|
||||
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID)",
|
||||
reason: '全量发布必须把与 wasm 构建一致的 Secret File 凭据透传给 Stdb Publish。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"choice(name: 'STDB_API_ROLLOUT_MODE', choices: ['pause-after-stdb', 'normal']",
|
||||
reason: 'Full Build 的 Stdb/API 首发门禁必须默认 fail-closed,不能由 cron 自动直落 API。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes:
|
||||
"booleanParam(name: 'KEEP_MAINTENANCE_MODE', value: params.STDB_API_ROLLOUT_MODE == 'pause-after-stdb')",
|
||||
reason: 'Full Build 暂停时必须要求 Stdb Publish 保持维护模式和旧运行时停止状态。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "timeout(time: 4, unit: 'HOURS')",
|
||||
reason: 'Stdb/API 人工门禁必须有超时,不能永久占用 disableConcurrentBuilds 锁。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
|
||||
includes: "submitter: params.STDB_API_ROLLOUT_APPROVERS.trim()",
|
||||
reason: 'Stdb/API 人工门禁必须限制指定 Jenkins 用户或组放行。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: '!options.apply && (result.has_more || pendingApplyCount > 0)',
|
||||
reason: '历史维护最后一批即使 has_more=false,只要 dry-run 仍命中数据也必须提示同 cursor apply。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: 'cursor_job_id: encodeSpacetimeCliOption(options.cursorJobId || null)',
|
||||
reason: '历史维护续批游标必须编码为 CLI SATS Option,不能把非空字符串直接传给 procedure。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-maintain-external-generation-jobs.mjs',
|
||||
includes: 'completed_before_micros: encodeSpacetimeCliOption(',
|
||||
reason: '历史 payload 截止时间必须编码为 CLI SATS Option,确保事故时间过滤可调用。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy-rust-remote.sh',
|
||||
excludes:
|
||||
|
||||
@@ -688,6 +688,7 @@ DEPLOY_COMPLETED=0
|
||||
PINGORA_INCLUDED=0
|
||||
REQUIRE_PINGORA_GATEWAY=0
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=0
|
||||
MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}"
|
||||
CURRENT_LINK_SWITCHED=0
|
||||
RELEASE_DIR=""
|
||||
STAGING_RELEASE_DIR=""
|
||||
@@ -847,8 +848,12 @@ on_exit() {
|
||||
|
||||
trap on_exit EXIT
|
||||
|
||||
if [[ ! -f "${MAINTENANCE_FILE}" ]]; then
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=1
|
||||
else
|
||||
echo "[production-api-deploy] 继承已有维护模式;部署失败时不得误退出上游维护窗口: ${MAINTENANCE_FILE}"
|
||||
fi
|
||||
bash "${SCRIPT_DIR}/maintenance-on.sh" "api deploy ${VERSION}"
|
||||
MAINTENANCE_ENABLED_BY_DEPLOY=1
|
||||
|
||||
echo "[production-api-deploy] 校验 api-server"
|
||||
(
|
||||
|
||||
@@ -6,16 +6,18 @@ umask 077
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> --migration-bootstrap-secret-file <protected-file> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--api-health-url http://127.0.0.1:8082/healthz] [--api-readiness-timeout-seconds 60] [--clear-database] [--backup-mode async|sync|skip]
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> --migration-bootstrap-secret-file <protected-file> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--api-health-url http://127.0.0.1:8082/healthz] [--api-readiness-timeout-seconds 60] [--keep-maintenance-mode] [--backup-mode async|sync|skip]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
|
||||
publish 固定使用 --delete-data=never 与 scoped --yes=migrate,break-clients;任何需要删除数据的 schema 冲突都会阻断发布。
|
||||
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
|
||||
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
|
||||
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
|
||||
async 模式会在 publish 前先做本地冷备份,再在 publish 完成后后台上传 OSS,避免低带宽上传阻塞部署。
|
||||
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
|
||||
发布成功后会补齐生产 API/worker env 的固定 bootstrap secret FILE 路径,再重启并验活重启前 active 的服务。
|
||||
--keep-maintenance-mode 会在 publish 前停止旧 API/controller/worker,并在成功后保持维护态,交由后续 API deploy 恢复服务。
|
||||
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
|
||||
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
|
||||
失败时保留维护模式。
|
||||
@@ -51,7 +53,7 @@ RUN_AS_USER="spacetimedb"
|
||||
MIGRATION_BOOTSTRAP_SECRET_FILE=""
|
||||
API_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_API_ENV_FILE:-/etc/genarrative/api-server.env}"
|
||||
WORKER_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
|
||||
CLEAR_DATABASE=0
|
||||
KEEP_MAINTENANCE_MODE=0
|
||||
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
|
||||
DEPLOY_COMPLETED=0
|
||||
PUBLISH_TMP_DIR=""
|
||||
@@ -285,6 +287,54 @@ restart_runtime_services_after_bootstrap_secret_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
stop_runtime_services_for_rollout_gate() {
|
||||
local api_state=""
|
||||
local controller_state=""
|
||||
local worker_service=""
|
||||
local worker_units_output=""
|
||||
local -a services_to_stop=()
|
||||
|
||||
api_state="$(get_runtime_service_active_state genarrative-api.service)"
|
||||
controller_state="$(get_runtime_service_active_state genarrative-external-generation-controller.service)"
|
||||
if ! worker_units_output="$(
|
||||
run_privileged systemctl list-units \
|
||||
--type=service \
|
||||
--state=active \
|
||||
--no-legend \
|
||||
--plain \
|
||||
'genarrative-external-generation-worker@*.service'
|
||||
)"; then
|
||||
echo "[production-stdb-publish] 查询 active worker 服务失败,无法建立受控维护窗口。" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${controller_state}" == "active" ]]; then
|
||||
services_to_stop+=(genarrative-external-generation-controller.service)
|
||||
fi
|
||||
while read -r worker_service _; do
|
||||
if [[ "${worker_service}" =~ ^genarrative-external-generation-worker@[A-Za-z0-9_.@:-]+\.service$ ]]; then
|
||||
services_to_stop+=("${worker_service}")
|
||||
fi
|
||||
done <<<"${worker_units_output}"
|
||||
if [[ "${api_state}" == "active" ]]; then
|
||||
services_to_stop+=(genarrative-api.service)
|
||||
fi
|
||||
|
||||
if [[ "${#services_to_stop[@]}" -eq 0 ]]; then
|
||||
echo "[production-stdb-publish] 受控维护窗口开始前没有 active API/controller/worker。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] 停止旧运行时服务并保持维护态: ${services_to_stop[*]}"
|
||||
run_privileged systemctl stop "${services_to_stop[@]}"
|
||||
for worker_service in "${services_to_stop[@]}"; do
|
||||
if [[ "$(get_runtime_service_active_state "${worker_service}")" == "active" ]]; then
|
||||
echo "[production-stdb-publish] 运行时服务停止后仍为 active: ${worker_service}" >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
@@ -336,8 +386,8 @@ while [[ $# -gt 0 ]]; do
|
||||
API_READINESS_TIMEOUT_SECONDS="${2:?缺少 --api-readiness-timeout-seconds 的值}"
|
||||
shift 2
|
||||
;;
|
||||
--clear-database)
|
||||
CLEAR_DATABASE=1
|
||||
--keep-maintenance-mode)
|
||||
KEEP_MAINTENANCE_MODE=1
|
||||
shift
|
||||
;;
|
||||
--skip-backup)
|
||||
@@ -455,6 +505,7 @@ on_exit() {
|
||||
trap on_exit EXIT
|
||||
|
||||
prepare_async_backup() {
|
||||
local -a restart_service_args=()
|
||||
ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
|
||||
ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
@@ -464,6 +515,10 @@ prepare_async_backup() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
|
||||
restart_service_args+=(--restart-service-after genarrative-api.service)
|
||||
fi
|
||||
|
||||
ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)"
|
||||
echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS"
|
||||
node -- "${ASYNC_BACKUP_SCRIPT}" \
|
||||
@@ -471,7 +526,7 @@ prepare_async_backup() {
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--stop-service spacetimedb.service \
|
||||
--restart-service-after genarrative-api.service \
|
||||
"${restart_service_args[@]}" \
|
||||
--defer-upload \
|
||||
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
|
||||
}
|
||||
@@ -555,12 +610,16 @@ wait_for_api_healthz_ready() {
|
||||
}
|
||||
|
||||
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
|
||||
stop_runtime_services_for_rollout_gate
|
||||
fi
|
||||
|
||||
case "${BACKUP_MODE}" in
|
||||
async)
|
||||
prepare_async_backup
|
||||
;;
|
||||
sync)
|
||||
SYNC_BACKUP_RESTART_SERVICE_ARGS=()
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
@@ -569,6 +628,9 @@ case "${BACKUP_MODE}" in
|
||||
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
|
||||
SYNC_BACKUP_RESTART_SERVICE_ARGS+=(--restart-service-after genarrative-api.service)
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布"
|
||||
node -- "${BACKUP_SCRIPT}" \
|
||||
@@ -576,7 +638,7 @@ case "${BACKUP_MODE}" in
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--stop-service spacetimedb.service \
|
||||
--restart-service-after genarrative-api.service
|
||||
"${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}"
|
||||
;;
|
||||
skip)
|
||||
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
||||
@@ -596,7 +658,8 @@ PUBLISH_ARGS=(
|
||||
publish
|
||||
"${DATABASE}"
|
||||
--bin-path "${SOURCE_DIR}/spacetime_module.wasm"
|
||||
--yes
|
||||
--delete-data=never
|
||||
--yes=migrate,break-clients
|
||||
--no-config
|
||||
)
|
||||
|
||||
@@ -606,10 +669,6 @@ else
|
||||
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
|
||||
fi
|
||||
|
||||
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||
PUBLISH_ARGS+=(--clear-database)
|
||||
fi
|
||||
|
||||
if [[ -n "${SERVER_URL}" ]]; then
|
||||
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}, root=${SPACETIME_ROOT_DIR}"
|
||||
else
|
||||
@@ -629,7 +688,8 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
publish
|
||||
"${DATABASE}"
|
||||
--bin-path "${PUBLISH_TMP_DIR}/spacetime_module.wasm"
|
||||
--yes
|
||||
--delete-data=never
|
||||
--yes=migrate,break-clients
|
||||
--no-config
|
||||
)
|
||||
if [[ -n "${SERVER_URL}" ]]; then
|
||||
@@ -637,9 +697,6 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
|
||||
else
|
||||
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
|
||||
fi
|
||||
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||
PUBLISH_ARGS+=(--clear-database)
|
||||
fi
|
||||
runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}"
|
||||
else
|
||||
spacetime "${PUBLISH_ARGS[@]}"
|
||||
@@ -668,6 +725,11 @@ run_privileged runuser -u genarrative -- test -r "${RUNTIME_SERVICE_BOOTSTRAP_SE
|
||||
echo "[production-stdb-publish] 已安装运行时服务身份引导密钥: ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}"
|
||||
ensure_runtime_bootstrap_secret_env_file "${API_ENV_FILE}" true
|
||||
ensure_runtime_bootstrap_secret_env_file "${WORKER_ENV_FILE}" false
|
||||
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
|
||||
echo "[production-stdb-publish] module 发布完成;按参数保持维护模式和旧运行时服务停止状态,等待人工维护与 API deploy。"
|
||||
DEPLOY_COMPLETED=1
|
||||
exit 0
|
||||
fi
|
||||
restart_runtime_services_after_bootstrap_secret_install
|
||||
wait_for_api_healthz_ready
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
callSpacetimeProcedureViaCli,
|
||||
encodeSpacetimeCliOption,
|
||||
ensureProcedureOk,
|
||||
parsePositiveInteger,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
const MAX_BATCH_SIZE = 25;
|
||||
|
||||
function usage() {
|
||||
return `用法:
|
||||
node scripts/spacetime-maintain-external-generation-jobs.mjs --database <name> [选项]
|
||||
|
||||
默认只 dry-run 一批历史终态任务 payload 压缩,不修改数据库。
|
||||
|
||||
公共选项:
|
||||
--database <name> 目标数据库(必填,也可用 GENARRATIVE_SPACETIME_DATABASE)
|
||||
--server <name-or-url> spacetime CLI server 名或 URL
|
||||
--server-url <url> 显式 server URL
|
||||
--limit <1-${MAX_BATCH_SIZE}> 单批任务数,默认 10
|
||||
--cursor-job-id <jobId> 从上一批 next_cursor_job_id 继续
|
||||
--apply 执行写入;省略时始终 dry-run
|
||||
--backfill-summaries 改为回填轻量摘要投影
|
||||
--owner-user-id <userId> 仅摘要回填可选,限定 owner
|
||||
--completed-before-micros <n> 仅 payload 压缩可选,限定终态完成时间
|
||||
--help 显示帮助
|
||||
|
||||
必须使用已授权 migration operator 的 spacetime CLI 登录态。脚本每次只处理一批;
|
||||
根据返回的 next_cursor_job_id 与 has_more 手工继续,避免在生产一次长事务扫完整历史。`;
|
||||
}
|
||||
|
||||
function parseOptions(argv) {
|
||||
const options = {
|
||||
apply: false,
|
||||
backfillSummaries: false,
|
||||
completedBeforeMicros: null,
|
||||
cursorJobId: '',
|
||||
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
|
||||
limit: 10,
|
||||
ownerUserId: '',
|
||||
passthrough: [],
|
||||
server: process.env.GENARRATIVE_SPACETIME_SERVER || '',
|
||||
serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (arg === '--database') {
|
||||
options.database = readValue(arg);
|
||||
} else if (arg === '--server') {
|
||||
options.server = readValue(arg);
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue(arg);
|
||||
} else if (arg === '--limit') {
|
||||
options.limit = parsePositiveInteger(readValue(arg), arg);
|
||||
} else if (arg === '--cursor-job-id') {
|
||||
options.cursorJobId = readValue(arg).trim();
|
||||
} else if (arg === '--completed-before-micros') {
|
||||
const value = readValue(arg);
|
||||
if (!/^-?[0-9]+$/u.test(value)) {
|
||||
throw new Error(`${arg} 必须是整数。`);
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${arg} 超出 JavaScript 安全整数范围。`);
|
||||
}
|
||||
options.completedBeforeMicros = parsed;
|
||||
} else if (arg === '--owner-user-id') {
|
||||
options.ownerUserId = readValue(arg).trim();
|
||||
} else if (arg === '--apply') {
|
||||
options.apply = true;
|
||||
} else if (arg === '--backfill-summaries') {
|
||||
options.backfillSummaries = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.limit > MAX_BATCH_SIZE) {
|
||||
throw new Error(`--limit 不能超过 ${MAX_BATCH_SIZE}。`);
|
||||
}
|
||||
if (options.ownerUserId && !options.backfillSummaries) {
|
||||
throw new Error('--owner-user-id 只能与 --backfill-summaries 一起使用。');
|
||||
}
|
||||
if (options.completedBeforeMicros !== null && options.backfillSummaries) {
|
||||
throw new Error('--completed-before-micros 不能用于摘要回填。');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseOptions(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (!options.database) {
|
||||
throw new Error(
|
||||
'必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。',
|
||||
);
|
||||
}
|
||||
|
||||
const procedureName = options.backfillSummaries
|
||||
? 'backfill_external_generation_job_summaries_and_return'
|
||||
: 'compact_external_generation_job_payloads_and_return';
|
||||
const input = 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(
|
||||
options.completedBeforeMicros,
|
||||
),
|
||||
};
|
||||
const result = await callSpacetimeProcedureViaCli(
|
||||
options,
|
||||
procedureName,
|
||||
input,
|
||||
);
|
||||
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);
|
||||
if (result.has_more && options.apply) {
|
||||
console.log(
|
||||
`仍有后续批次;下一次追加 --cursor-job-id ${result.next_cursor_job_id ?? '<missing>'}。`,
|
||||
);
|
||||
} else if (!options.apply && (result.has_more || pendingApplyCount > 0)) {
|
||||
const currentCursor = options.cursorJobId
|
||||
? `保留 --cursor-job-id ${options.cursorJobId}`
|
||||
: '仍从首批开始';
|
||||
console.log(
|
||||
`当前仅 dry-run;请${currentCursor}并追加 --apply 重跑同一批。apply 成功后再使用其 next_cursor_job_id 进入下一批。`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[spacetime:external-generation:maintenance] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -109,6 +109,10 @@ export function parsePositiveInteger(value, name) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function encodeSpacetimeCliOption(value) {
|
||||
return value === null || value === undefined ? null : [0, value];
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInteger(value, name) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
@@ -171,7 +175,7 @@ export async function callSpacetimeProcedure(options, procedureName, input) {
|
||||
);
|
||||
}
|
||||
|
||||
return parseProcedureResult(text);
|
||||
return parseProcedureResult(text, procedureName);
|
||||
}
|
||||
|
||||
export async function createSpacetimeWebIdentity(options) {
|
||||
@@ -225,7 +229,7 @@ export async function callSpacetimeProcedureAuto(options, procedureName, input)
|
||||
export async function callSpacetimeProcedureViaCli(options, procedureName, input) {
|
||||
const args = buildSpacetimeCallArgs(options, procedureName, input);
|
||||
const output = await runSpacetimeCli(args);
|
||||
return parseProcedureResult(output);
|
||||
return parseProcedureResult(output, procedureName);
|
||||
}
|
||||
|
||||
export function validateSpacetimeDatabaseName(database) {
|
||||
@@ -236,7 +240,7 @@ export function validateSpacetimeDatabaseName(database) {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProcedureResult(output) {
|
||||
export function parseProcedureResult(output, procedureName = '') {
|
||||
const candidates = [];
|
||||
const trimmed = output.trim();
|
||||
if (trimmed) {
|
||||
@@ -252,7 +256,7 @@ export function parseProcedureResult(output) {
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return normalizeProcedureResult(JSON.parse(candidate));
|
||||
return normalizeProcedureResult(JSON.parse(candidate), procedureName);
|
||||
} catch {
|
||||
// SpacetimeDB CLI 在不同版本中可能附带说明文本,继续尝试后续候选。
|
||||
}
|
||||
@@ -275,19 +279,55 @@ export async function assertReadableFile(filePath) {
|
||||
await access(path.resolve(filePath));
|
||||
}
|
||||
|
||||
function normalizeProcedureResult(value) {
|
||||
function normalizeProcedureResult(value, procedureName) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeSatsProduct(value);
|
||||
return normalizeSatsProduct(value, procedureName);
|
||||
}
|
||||
|
||||
throw new Error('procedure 返回值不是对象。');
|
||||
}
|
||||
|
||||
function normalizeSatsProduct(value) {
|
||||
function normalizeSatsProduct(value, procedureName) {
|
||||
if (
|
||||
procedureName === 'backfill_external_generation_job_summaries_and_return' &&
|
||||
value.length === 8
|
||||
) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
dry_run: normalizeSatsValue(value[1]),
|
||||
scanned_count: normalizeSatsValue(value[2]),
|
||||
selected_count: normalizeSatsValue(value[3]),
|
||||
upserted_count: normalizeSatsValue(value[4]),
|
||||
next_cursor_job_id: normalizeSatsOption(value[5]),
|
||||
has_more: normalizeSatsValue(value[6]),
|
||||
error_message: normalizeSatsOption(value[7]),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
procedureName === 'compact_external_generation_job_payloads_and_return' &&
|
||||
value.length === 12
|
||||
) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
dry_run: normalizeSatsValue(value[1]),
|
||||
scanned_count: normalizeSatsValue(value[2]),
|
||||
matched_count: normalizeSatsValue(value[3]),
|
||||
updated_count: normalizeSatsValue(value[4]),
|
||||
before_bytes: normalizeSatsValue(value[5]),
|
||||
after_bytes: normalizeSatsValue(value[6]),
|
||||
inline_media_count: normalizeSatsValue(value[7]),
|
||||
invalid_json_count: normalizeSatsValue(value[8]),
|
||||
next_cursor_job_id: normalizeSatsOption(value[9]),
|
||||
has_more: normalizeSatsValue(value[10]),
|
||||
error_message: normalizeSatsOption(value[11]),
|
||||
};
|
||||
}
|
||||
|
||||
if (value.length === 3) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSpacetimeCallArgs,
|
||||
encodeSpacetimeCliOption,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
describe('SpacetimeDB CLI SATS option encoding', () => {
|
||||
it('keeps absent options null and wraps present values as Some', () => {
|
||||
expect(encodeSpacetimeCliOption(null)).toBeNull();
|
||||
expect(encodeSpacetimeCliOption(undefined)).toBeNull();
|
||||
expect(encodeSpacetimeCliOption('job-2')).toEqual([0, 'job-2']);
|
||||
expect(encodeSpacetimeCliOption(123)).toEqual([0, 123]);
|
||||
});
|
||||
|
||||
it('serializes non-empty maintenance cursors in the CLI procedure input', () => {
|
||||
const args = buildSpacetimeCallArgs(
|
||||
{
|
||||
database: 'genarrative-prod',
|
||||
passthrough: [],
|
||||
serverUrl: 'http://127.0.0.1:3311',
|
||||
},
|
||||
'compact_external_generation_job_payloads_and_return',
|
||||
{
|
||||
dry_run: true,
|
||||
limit: 1,
|
||||
cursor_job_id: encodeSpacetimeCliOption('job-2'),
|
||||
completed_before_micros: encodeSpacetimeCliOption(123),
|
||||
},
|
||||
);
|
||||
|
||||
const input = JSON.parse(args.at(-2) ?? 'null');
|
||||
expect(input.cursor_job_id).toEqual([0, 'job-2']);
|
||||
expect(input.completed_before_micros).toEqual([0, 123]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user