test: harden conditional cleanup boundaries

This commit is contained in:
2026-07-31 08:25:08 +00:00
parent a568a8e747
commit 4359d8dadd
3 changed files with 128 additions and 33 deletions
@@ -20,7 +20,8 @@ afterEach(() => {
liveDescribe('battle simulator worker with live Redis', () => {
it('consumes an isolated queue, produces a result, and stops cleanly', { timeout: 30_000 }, async () => {
const scenario = `battle-sim-e2e-${randomUUID()}`;
const namespace = process.env.CONDITIONAL_INTEGRATION_RUN_ID ?? randomUUID();
const scenario = `battle-sim-e2e-${namespace}-${randomUUID()}`;
const profileName = `che:${scenario}`;
const requesterUserId = 'worker-e2e-user';
vi.stubEnv('PROFILE', 'che');
+10
View File
@@ -22,6 +22,13 @@ schema 환경 변수를 지정한 경우에도 이미 존재하는 schema는 거
HTTP transport fixture의 Redis key도 실행 ID를 profile namespace에 포함하고,
runner 종료 시 그 실행 ID에 속한 key만 삭제합니다. 공유 Redis 전체에
`FLUSHDB``FLUSHALL`을 실행하지 않습니다.
Battle simulator fixture도 실행 ID가 포함된 queue/result/notify namespace를
사용합니다. 실행 중단 시 테스트의 `finally`가 실행되지 않아도 runner가 같은
실행 ID의 key만 찾아 삭제합니다.
Runner는 test process group에 종료 신호를 전달하고 기본 10초 안에 종료되지
않으면 `SIGKILL`로 전환한 뒤 schema와 Redis cleanup을 계속합니다. 이 유예
시간은 중단 경계 검증에서만 `CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS`
(1~60초)로 줄일 수 있습니다.
`SIGKILL`은 cleanup trap을 실행할 수 없으므로 자동 정리를 보장하지 않습니다.
## 준비
@@ -71,6 +78,9 @@ runtime role을 삭제하고 PID와 명령행 및 daemon 종료를 확인한 뒤
환경 변수는 `tools/conditional-integration-registry.tsv`에서 명시적으로
관리합니다. 새 `*_DATABASE_URL` gate가 registry에 없거나 registry 항목이
더 이상 테스트에 존재하지 않으면 runner가 테스트 실행 전에 실패합니다.
Registry는 marker 존재 여부뿐 아니라 중복, 형식과 지원 execution mode도
검사합니다. 지원하지 않는 mode로 인해 test가 실행 group에서 빠지는 경우에도
runner는 test 시작 전에 실패합니다.
관리자 시간 조정의 PostgreSQL 경계는
`runtimeClockShiftPersistence.integration.test.ts`, gateway action
+116 -32
View File
@@ -40,6 +40,19 @@ set +a
node_tag=$(printf '%s' "${CI_NODE_INDEX:-local}" | tr -cd 'a-zA-Z0-9_' | tr 'A-Z' 'a-z' | cut -c1-8)
run_id=$(date -u +%m%d%H%M%S)_$$_${node_tag}
export CONDITIONAL_INTEGRATION_RUN_ID=$run_id
schema_ownership_token="sammo-conditional-integration:$run_id"
supported_registry_modes="core create_general gateway_runtime immediate_action npc_possession reference_live_sortie reference_npc_possession select_pool"
term_grace_seconds=${CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS:-10}
case "$term_grace_seconds" in
''|*[!0-9]*)
echo "CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS must be an integer from 1 to 60" >&2
exit 64
;;
esac
if [ "$term_grace_seconds" -lt 1 ] || [ "$term_grace_seconds" -gt 60 ]; then
echo "CONDITIONAL_INTEGRATION_TERM_GRACE_SECONDS must be an integer from 1 to 60" >&2
exit 64
fi
integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-ci_${run_id}_integration}
scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-ci_${run_id}_scenario_seed}
npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-ci_${run_id}_npc_possession_integration}
@@ -74,8 +87,9 @@ done
report_dir=$(mktemp -d)
summary_file="$report_dir/summary.tsv"
owned_schemas=
validated_registry_file="$report_dir/validated-registry.tsv"
active_process_group=
cleanup_resources_started=0
build_database_url() {
SCHEMA_NAME=$1 node --input-type=module -e '
@@ -107,39 +121,56 @@ base_database_url=$(build_database_url public)
create_owned_schema() {
schema=$1
SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \
SCHEMA_NAME=$schema SCHEMA_OWNERSHIP_TOKEN=$schema_ownership_token \
DATABASE_URL=$base_database_url \
pnpm --filter @sammo-ts/infra exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
const apostrophe = String.fromCharCode(39);
const quoteLiteral = (value) =>
`${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`;
await client.connect();
try {
await client.query(`CREATE SCHEMA "${process.env.SCHEMA_NAME}"`);
await client.query("BEGIN");
await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
await client.query(
`COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral(
process.env.SCHEMA_OWNERSHIP_TOKEN
)}`
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
await client.end();
}
'
owned_schemas="${owned_schemas}${schema}
"
}
drop_owned_schemas() {
cleanup_failed=0
for schema in $owned_schemas; do
if ! SCHEMA_NAME=$schema DATABASE_URL=$base_database_url \
pnpm --filter @sammo-ts/infra exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
await client.query(`DROP SCHEMA IF EXISTS "${process.env.SCHEMA_NAME}" CASCADE`);
} finally {
await client.end();
SCHEMA_OWNERSHIP_TOKEN=$schema_ownership_token DATABASE_URL=$base_database_url \
pnpm --filter @sammo-ts/infra exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
await client.connect();
try {
const result = await client.query(
`SELECT nspname
FROM pg_namespace
WHERE obj_description(oid, $$pg_namespace$$) = $1
ORDER BY nspname`,
[process.env.SCHEMA_OWNERSHIP_TOKEN]
);
for (const { nspname } of result.rows) {
await client.query(`DROP SCHEMA ${quoteIdentifier(nspname)} CASCADE`);
}
' >/dev/null; then
cleanup_failed=1
fi
done
return "$cleanup_failed"
} finally {
await client.end();
}
' >/dev/null
}
delete_owned_redis_keys() {
@@ -151,6 +182,7 @@ delete_owned_redis_keys() {
const patterns = [
`sammo:game:*:che:security-http-${runId}:*`,
`sammo:game:*:che:nation-html-${runId}:*`,
`sammo:che:battle-sim-e2e-${runId}-*:battle-sim:*`,
];
await client.connect();
try {
@@ -169,8 +201,19 @@ delete_owned_redis_keys() {
terminate_active_process_group() {
[ -n "$active_process_group" ] || return 0
kill -TERM "-$active_process_group" 2>/dev/null || true
wait "$active_process_group" 2>/dev/null || true
process_group=$active_process_group
kill -TERM "-$process_group" 2>/dev/null || true
waited_seconds=0
while kill -0 "-$process_group" 2>/dev/null &&
[ "$waited_seconds" -lt "$term_grace_seconds" ]; do
sleep 1
waited_seconds=$((waited_seconds + 1))
done
if kill -0 "-$process_group" 2>/dev/null; then
echo "conditional integration process group did not stop after ${term_grace_seconds}s; sending SIGKILL" >&2
kill -KILL "-$process_group" 2>/dev/null || true
fi
wait "$process_group" 2>/dev/null || true
active_process_group=
}
@@ -196,11 +239,13 @@ handle_exit() {
exit_status=$?
trap - EXIT HUP INT TERM
terminate_active_process_group
if ! drop_owned_schemas && [ "$exit_status" -eq 0 ]; then
exit_status=1
fi
if ! delete_owned_redis_keys && [ "$exit_status" -eq 0 ]; then
exit_status=1
if [ "$cleanup_resources_started" -eq 1 ]; then
if ! drop_owned_schemas && [ "$exit_status" -eq 0 ]; then
exit_status=1
fi
if ! delete_owned_redis_keys && [ "$exit_status" -eq 0 ]; then
exit_status=1
fi
fi
print_summary
rm -rf "$report_dir"
@@ -215,6 +260,7 @@ trap 'exit 143' TERM
validate_marker_registry() {
discovered_file="$report_dir/discovered-markers.txt"
registered_file="$report_dir/registered-markers.txt"
registry_errors_file="$report_dir/registry-errors.txt"
missing_file="$report_dir/unregistered-markers.txt"
stale_file="$report_dir/stale-markers.txt"
@@ -229,7 +275,44 @@ validate_marker_registry() {
sed 's/process\.env\.//' |
sort -u
) >"$discovered_file"
awk -F '\t' '!/^#/ && NF == 2 { print $1 }' "$registry_file" | sort -u >"$registered_file"
: >"$validated_registry_file"
awk -F '\t' -v supported_modes="$supported_registry_modes" \
-v validated_registry_file="$validated_registry_file" '
BEGIN {
split(supported_modes, mode_names, " ");
for (mode_index in mode_names) {
allowed_modes[mode_names[mode_index]] = 1;
}
}
/^#/ || /^[[:space:]]*$/ { next }
NF != 2 {
printf "line %d must contain exactly one tab-separated marker and mode\n", NR;
failed = 1;
next;
}
$1 !~ /^[A-Z][A-Z0-9_]*_DATABASE_URL$/ {
printf "line %d has invalid marker: %s\n", NR, $1;
failed = 1;
}
!($2 in allowed_modes) {
printf "line %d has unsupported execution mode: %s\n", NR, $2;
failed = 1;
}
seen[$1]++ {
printf "line %d duplicates marker: %s\n", NR, $1;
failed = 1;
}
{
print $1 "\t" $2 > validated_registry_file;
}
END { exit failed }
' "$registry_file" >"$registry_errors_file" || {
echo "invalid conditional integration marker registry:" >&2
sed 's/^/ /' "$registry_errors_file" >&2
exit 65
}
sort -u "$validated_registry_file" -o "$validated_registry_file"
cut -f1 "$validated_registry_file" >"$registered_file"
comm -23 "$discovered_file" "$registered_file" >"$missing_file"
if [ -s "$missing_file" ]; then
@@ -248,7 +331,7 @@ validate_marker_registry() {
markers_for_mode() {
mode=$1
awk -F '\t' -v mode="$mode" '!/^#/ && $2 == mode { print $1 }' "$registry_file" |
awk -F '\t' -v mode="$mode" '$2 == mode { print $1 }' "$validated_registry_file" |
paste -sd '|' -
}
@@ -348,13 +431,14 @@ run_redis_only_tests() {
export PATH
cd "$workspace_root"
validate_marker_registry
pnpm install --frozen-lockfile
pnpm --filter @sammo-ts/infra prisma:generate
pnpm --filter @sammo-ts/common build
pnpm --filter @sammo-ts/infra build
validate_marker_registry
cleanup_resources_started=1
create_owned_schema "$integration_schema"
create_owned_schema "$npc_possession_schema"
create_owned_schema "$scenario_schema"