Skip to content
Merged
14 changes: 12 additions & 2 deletions tools/ci/decide-retained-gwangju-timetable-refresh.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { deriveFreshnessExpiresAt } from "../datapack/freshness-policy.mjs";
import { addCadence, deriveFreshnessExpiresAt } from "../datapack/freshness-policy.mjs";
import { validateLineage } from "../datapack/source-snapshot-policy.mjs";
import { requireRetainedTimetableConfirmationPolicy } from "../datapack/prepare-retained-kric-timetable-publication.mjs";

const SOURCE_ID = "kric-nationwide-timetable-file";
// #929 D2 / #930 F2: 일일 재확인 주기. QA 결정 D2(a)(2026-10-04)로 관측 후 P1D가 지나면 재확인한다.
// 정책 클래스 cadence(official_static_timetable_confirmation P7D)는 만료(freshnessExpiresAt)를 정하는 값이고 그대로 둔다.
// 이 값은 만료가 아니라 재확인 시작 시각이므로 cadence보다 짧아야 한다(계약 테스트로 고정).
export const RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD = "P1D";

// Workflow와 controller는 동일한 current 입력을 읽고, 운영 시각은 호출 시 한 번 캡처한다.
export async function readRetainedGwangjuTimetableRefreshDecision({
Expand All @@ -24,6 +28,7 @@ export async function readRetainedGwangjuTimetableRefreshDecision({

// 등록된 head와 발행 경로가 공유하는 정책으로 갱신 시점을 계산한다.
// #903: 만료 뒤가 아니라 SLA monitoring.alertBeforePackExpiry(수도권 topology 갱신 판정과 같은 기준) 창이 시작될 때부터 DUE다.
// #929 D2: 그보다 먼저, 관측 후 하루가 지나면 DUE다(일일 재확인).
// 만료 시각 자체는 바꾸지 않는다(연장 없음). 창 안에서 새로 수집해 등록해야 만료 전에 head가 이어진다.
export function decideRetainedGwangjuTimetableRefresh({ inventory, snapshots, candidate, freshnessPolicy, now = new Date() } = {}) {
const nowMillis = requiredDate(now, "NOW");
Expand All @@ -47,7 +52,12 @@ export function decideRetainedGwangjuTimetableRefresh({ inventory, snapshots, ca
if (head.freshnessExpiresAt !== freshnessExpiresAt || head.freshUntil !== freshnessExpiresAt) {
fail("FRESHNESS_EXPIRES_AT");
}
const refreshDueAt = new Date(requiredUtc(freshnessExpiresAt, "FRESHNESS_EXPIRES_AT") - alertBeforeExpiryMillis).toISOString();
// #929 D2(QA 결정 2026-10-04): 관측 후 P1D가 지나면 매일 재확인한다. 만료 경보 창이 그보다 먼저 오면 그 시각이 우선이다.
// 만료 시각(freshnessExpiresAt) 계산은 바꾸지 않는다.
const refreshDueAt = new Date(Math.min(
addCadence(observedMillis, RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD),
requiredUtc(freshnessExpiresAt, "FRESHNESS_EXPIRES_AT") - alertBeforeExpiryMillis,
)).toISOString();
return {
state: nowMillis < Date.parse(refreshDueAt) ? "CURRENT" : "DUE",
sourceId: SOURCE_ID, snapshotId: head.snapshotId, observedAt: head.observedAt, freshnessExpiresAt, refreshDueAt,
Expand Down
61 changes: 50 additions & 11 deletions tools/ci/decide-retained-gwangju-timetable-refresh.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { decideRetainedGwangjuTimetableRefresh, readRetainedGwangjuTimetableRefreshDecision } from "./decide-retained-gwangju-timetable-refresh.mjs";
import {
RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD,
decideRetainedGwangjuTimetableRefresh,
readRetainedGwangjuTimetableRefreshDecision,
} from "./decide-retained-gwangju-timetable-refresh.mjs";
import { readFileSync } from "node:fs";

const SOURCE_ID = "kric-nationwide-timetable-file";
const OBSERVED_AT = "2026-09-07T10:50:18.169Z";
Expand All @@ -15,6 +20,8 @@ const snapshotId = `${SOURCE_ID}-${observationIdentitySha256}`;
// SLA monitoring.alertBeforePackExpiry(수도권 topology 갱신 판정과 같은 기준)
const ALERT_BEFORE_EXPIRY_MS = 6 * 60 * 60 * 1_000;
const REFRESH_DUE_AT = new Date(Date.parse(EXPIRY) - ALERT_BEFORE_EXPIRY_MS).toISOString();
// #929 D2(QA 결정 2026-10-04): 관측 후 P1D가 지나면 매일 재확인한다.
const DAILY_DUE_AT = new Date(Date.parse(OBSERVED_AT) + 24 * 60 * 60 * 1_000).toISOString();
const freshnessPolicy = (alertBeforePackExpiry = "PT6H") => ({ monitoring: { alertBeforePackExpiry } });

function candidate(cadence = "P7D") {
Expand Down Expand Up @@ -64,26 +71,40 @@ test("repository decision reads the admitted head and rejects duplicate source p
await writeJson("tools/datapack/release/source-snapshots.json", value.snapshots);
await writeJson("tools/datapack/source-candidates.json", { candidates: [value.candidate] });
await writeJson("release/product-gates/datapack-freshness-sla.json", value.freshnessPolicy);
const result = await readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(REFRESH_DUE_AT) });
const result = await readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(DAILY_DUE_AT) });
assert.equal(result.state, "DUE");
assert.equal(result.refreshDueAt, REFRESH_DUE_AT);
assert.equal(result.refreshDueAt, DAILY_DUE_AT);
assert.equal(result.snapshotId, snapshotId);
await writeJson("tools/datapack/source-candidates.json", { candidates: [value.candidate, value.candidate] });
await assert.rejects(readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(EXPIRY) }),
/RETAINED_GWANGJU_TIMETABLE_REFRESH_SOURCE_CANDIDATE/);
});

test("retained Gwangju timetable은 genuine head가 아직 만료 전이면 CURRENT다", () => {
const result = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date("2026-09-10T00:00:00.000Z") });
test("retained Gwangju timetable은 관측 후 하루가 지나기 전에는 CURRENT다", () => {
// #929 D2 전에는 만료 6시간 전(REFRESH_DUE_AT)까지 CURRENT였다. 매일 재확인으로 바뀌어 기준 시각을 관측 다음 날로 옮긴다.
const result = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date("2026-09-08T00:00:00.000Z") });
assert.deepEqual(result, { state: "CURRENT", sourceId: SOURCE_ID, snapshotId, observedAt: OBSERVED_AT,
freshnessExpiresAt: EXPIRY, refreshDueAt: REFRESH_DUE_AT });
freshnessExpiresAt: EXPIRY, refreshDueAt: DAILY_DUE_AT });
});

test("retained Gwangju timetable은 만료 전 SLA 경보 창(PT6H) 시작부터 DUE다", () => {
assert.equal(decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(Date.parse(REFRESH_DUE_AT) - 1) }).state, "CURRENT");
assert.equal(decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(REFRESH_DUE_AT) }).state, "DUE");
const wider = { ...inputs(), freshnessPolicy: freshnessPolicy("PT24H") };
assert.equal(decideRetainedGwangjuTimetableRefresh({ ...wider, now: new Date(Date.parse(EXPIRY) - 24 * 60 * 60 * 1_000) }).state, "DUE");
test("#929 D2 retained Gwangju timetable은 관측 후 P1D부터 DUE이고 만료 계산은 그대로다", () => {
const before = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(Date.parse(DAILY_DUE_AT) - 1) });
assert.equal(before.state, "CURRENT");
const due = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(DAILY_DUE_AT) });
assert.equal(due.state, "DUE");
assert.equal(due.refreshDueAt, DAILY_DUE_AT);
assert.equal(due.freshnessExpiresAt, EXPIRY);
assert.ok(Date.parse(DAILY_DUE_AT) < Date.parse(REFRESH_DUE_AT));
});

test("retained Gwangju timetable은 만료 전 SLA 경보 창(PT6H)이 하루보다 먼저 오면 그때부터 DUE다", () => {
const cutoff = new Date(Date.parse(OBSERVED_AT) + 12 * 60 * 60 * 1_000).toISOString();
const value = inputs({ head: snapshot({ serviceEffectiveUntil: cutoff, freshnessExpiresAt: cutoff, freshUntil: cutoff }) });
const alertDueAt = new Date(Date.parse(cutoff) - ALERT_BEFORE_EXPIRY_MS).toISOString();
assert.equal(decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date(Date.parse(alertDueAt) - 1) }).state, "CURRENT");
const due = decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date(alertDueAt) });
assert.equal(due.state, "DUE");
assert.equal(due.refreshDueAt, alertDueAt);
});

test("SLA 경보 창이 없거나 형식이 틀리면 판정하지 않는다", () => {
Expand Down Expand Up @@ -143,3 +164,21 @@ test("inventory evidence mismatch와 future observation을 거부한다", () =>
/RETAINED_GWANGJU_TIMETABLE_REFRESH_FUTURE_OBSERVATION/,
);
});

// #930 리뷰 F2: 일일 재확인 주기(P1D)는 정책 클래스 cadence(P7D, 만료 기준)와 일부러 다르다.
// QA 결정 D2(a)(2026-10-04): 만료는 그대로 두고 재확인만 매일 한다. 두 값이 조용히 엇갈리지 않게 관계를 고정한다.
test("#930 F2 daily reverification is a named P1D period shorter than the admitted confirmation cadence", () => {
assert.equal(RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD, "P1D");
const candidates = JSON.parse(readFileSync(new URL("../datapack/source-candidates.json", import.meta.url), "utf8"));
const confirmation = candidates.candidates.find(({ id }) => id === SOURCE_ID).confirmationPolicy;
assert.equal(confirmation.id, "official_static_timetable_confirmation");
const days = (period) => Number(/^P([1-9][0-9]*)D$/u.exec(period)?.[1]);
assert.ok(days(RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD) < days(confirmation.reverificationCadence),
`daily reverification must come before the ${confirmation.reverificationCadence} expiry`);
// 정책 cadence가 바뀌어도 일일 재확인 시각은 관측 + P1D 그대로다(만료 계산과 독립).
const value = inputs();
value.candidate = candidate("P14D");
value.snapshots[0].freshnessExpiresAt = "2026-09-21T10:50:18.169Z";
value.snapshots[0].freshUntil = "2026-09-21T10:50:18.169Z";
assert.equal(decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date("2026-09-07T12:00:00.000Z") }).refreshDueAt, DAILY_DUE_AT);
});
42 changes: 40 additions & 2 deletions tools/datapack/build-current-five-region-source-fan-in.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { deriveFreshnessExpiresAt } from "./freshness-policy.mjs";

export const CURRENT_FIVE_REGION_SOURCE_FAN_IN_PATH =
"tools/datapack/release/current-five-region-source-fan-in.json";
export const NATIVE_ADMISSION_KINDS = Object.freeze([
Expand Down Expand Up @@ -202,7 +204,9 @@ function headAdmissionEvidence(source, sourceId, snapshot, evaluatedAt) {
const observedAt = typeof rawObservedAt === "string" && !isNaN(Date.parse(rawObservedAt))
? new Date(Date.parse(rawObservedAt)).toISOString()
: rawObservedAt;
const rawFreshUntil = evidence.freshUntil ?? snapshot.freshnessExpiresAt;
// #929 D1(QA 결정 2026-10-04): 등록 확인 창의 끝은 원천별 정책 신선도(원장 head freshnessExpiresAt)다.
// 등록 evidence의 freshUntil(등록기가 수집 +1일로 쓰던 값)은 창을 줄이지 않는다.
const rawFreshUntil = snapshot.freshnessExpiresAt;
const freshUntil = typeof rawFreshUntil === "string" && !isNaN(Date.parse(rawFreshUntil))
? new Date(Date.parse(rawFreshUntil)).toISOString()
: rawFreshUntil;
Expand Down Expand Up @@ -487,6 +491,38 @@ function selectedSources(rows, inventory, sourceSnapshots, evaluatedAt) {
});
}

// #929 D1: fan-in이 고른 원천은 신선도 정책 클래스가 정확히 하나여야 한다. 없거나 둘 이상이면 정책 기간을 정할 수 없다.
// #930 F1: 등록 창의 끝(원장 head freshnessExpiresAt)은 "등록 기준 시각 + 정책 기간"을 넘을 수 없다.
// 기준 시각은 클래스 basisField 값이고, 동일 원본 재확인 클래스는 재확인 시각이 더 늦으면 그 시각이다.
// 상한은 발행 FINAL과 같은 deriveFreshnessExpiresAt으로 구한다(제공처 유효 종료일은 상한을 낮추기만 하므로 넣지 않는다).
export function assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy, sourceSnapshots }) {
if (!Array.isArray(freshnessPolicy?.sourceClasses)
|| freshnessPolicy.sourceClasses.some((entry) => !Array.isArray(entry?.sourceIds))) {
throw new Error("fan-in freshness policy shape mismatch");
}
if (!Array.isArray(sourceSnapshots)) throw new Error("fan-in freshness policy needs the source snapshot ledger");
for (const { sourceId, snapshotId } of fanIn?.selectedSources ?? []) {
const classes = freshnessPolicy.sourceClasses.filter(({ sourceIds }) => sourceIds.includes(sourceId));
if (classes.length === 0) throw new Error(`fan-in freshness policy missing for ${sourceId}`);
if (classes.length > 1) throw new Error(`fan-in freshness policy ambiguous for ${sourceId}`);
const [sourceClass] = classes;
const heads = sourceSnapshots.filter((row) => row?.snapshotId === snapshotId && row.sourceId === sourceId);
if (heads.length !== 1) throw new Error(`fan-in freshness policy head mismatch for ${sourceId}`);
const [head] = heads;
const bases = [head[sourceClass.basisField], sourceClass.unchangedReverificationBasisField
? head[sourceClass.unchangedReverificationBasisField] : undefined].filter((value) => typeof value === "string");
if (bases.length === 0) throw new Error(`fan-in freshness policy basis missing for ${sourceId}`);
const basisAt = bases.reduce((latest, value) => (Date.parse(value) > Date.parse(latest) ? value : latest), bases[0]);
const ceiling = deriveFreshnessExpiresAt({
policy: freshnessPolicy, sourceClassId: sourceClass.id, basisAt, evaluationAt: fanIn.evaluatedAt,
});
const storedMillis = Date.parse(head.freshnessExpiresAt);
if (!Number.isFinite(storedMillis) || storedMillis > Date.parse(ceiling)) {
throw new Error(`fan-in freshness exceeds policy for ${sourceId}: ${head.freshnessExpiresAt} > ${ceiling}`);
}
}
}

export function canonicalCurrentFiveRegionSourceFanInJson(value) {
return canonical(value);
}
Expand Down Expand Up @@ -586,7 +622,7 @@ export function buildCurrentFiveRegionSourceFanIn(input = {}) {
}

function argumentsFrom(argv) {
const names = ["targets", "tally", "ownership", "inventory", "source-snapshots", "evaluated-at", "output"];
const names = ["targets", "tally", "ownership", "inventory", "source-snapshots", "freshness-policy", "evaluated-at", "output"];
if (argv.length !== names.length * 2) throw new Error("five-region fan-in arguments mismatch");
const result = {};
for (let index = 0; index < argv.length; index += 2) {
Expand Down Expand Up @@ -620,6 +656,8 @@ async function main() {
evaluatedAt: args["evaluated-at"],
};
const fanIn = buildCurrentFiveRegionSourceFanIn(input);
const freshnessPolicy = JSON.parse(await readFile(path.resolve(args["freshness-policy"]), "utf8"));
assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy, sourceSnapshots: input.sourceSnapshots });
await writeFile(path.resolve(args.output), `${canonicalCurrentFiveRegionSourceFanInJson(fanIn)}\n`, {
flag: "wx",
mode: 0o600,
Expand Down
Loading
Loading