06998ac65d
Validate keepPerFamily/--keep-per-family as non-negative integers, remove silent flooring, add regression coverage, and sync runbook/docs wording.
220 lines
6.3 KiB
TypeScript
220 lines
6.3 KiB
TypeScript
export type Phase0RollingArtifactFamily =
|
|
| 'channel'
|
|
| 'gateway'
|
|
| 'backend_pi_embedded'
|
|
| 'backend_native'
|
|
| 'backend_drift'
|
|
| 'prune';
|
|
|
|
export interface Phase0RollingArtifactFile {
|
|
file_name: string;
|
|
family: Phase0RollingArtifactFamily;
|
|
tag: string;
|
|
tag_timestamp_ms: number;
|
|
}
|
|
|
|
export interface Phase0RollingArtifactRetentionPlan {
|
|
keep: Phase0RollingArtifactFile[];
|
|
remove: Phase0RollingArtifactFile[];
|
|
families: Array<{
|
|
family: Phase0RollingArtifactFamily;
|
|
total_tags: number;
|
|
keep_tags: number;
|
|
remove_tags: number;
|
|
}>;
|
|
}
|
|
|
|
const ROLLING_TAG_PATTERN = /^(\d{4})-(\d{2})-(\d{2})-(\d{6})$/;
|
|
|
|
const FAMILY_PATTERNS: Array<{ family: Phase0RollingArtifactFamily; pattern: RegExp }> = [
|
|
{
|
|
family: 'channel',
|
|
pattern: /^phase0_baseline_live_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
|
|
},
|
|
{
|
|
family: 'gateway',
|
|
pattern: /^phase0_baseline_live_gateway_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
|
|
},
|
|
{
|
|
family: 'backend_pi_embedded',
|
|
pattern: /^phase0_baseline_live_backend_pi_embedded_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
|
|
},
|
|
{
|
|
family: 'backend_native',
|
|
pattern: /^phase0_baseline_live_backend_native_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|jsonl|md)$/,
|
|
},
|
|
{
|
|
family: 'backend_drift',
|
|
pattern: /^phase0_baseline_live_backend_drift_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|md)$/,
|
|
},
|
|
{
|
|
family: 'prune',
|
|
pattern: /^phase0_baseline_live_prune_(\d{4}-\d{2}-\d{2}-\d{6})\.(json|md)$/,
|
|
},
|
|
];
|
|
|
|
function parseRollingTagTimestampMs(tag: string): number | undefined {
|
|
const match = ROLLING_TAG_PATTERN.exec(tag);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
const hhmmss = match[4] ?? '';
|
|
|
|
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day) || hhmmss.length !== 6) {
|
|
return undefined;
|
|
}
|
|
|
|
const hour = Number(hhmmss.slice(0, 2));
|
|
const minute = Number(hhmmss.slice(2, 4));
|
|
const second = Number(hhmmss.slice(4, 6));
|
|
if (!Number.isFinite(hour) || !Number.isFinite(minute) || !Number.isFinite(second)) {
|
|
return undefined;
|
|
}
|
|
if (month < 1 || month > 12 || day < 1 || day > 31 || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
return undefined;
|
|
}
|
|
|
|
const timestampMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
if (!Number.isFinite(timestampMs)) {
|
|
return undefined;
|
|
}
|
|
|
|
const utc = new Date(timestampMs);
|
|
if (
|
|
utc.getUTCFullYear() !== year
|
|
|| utc.getUTCMonth() !== month - 1
|
|
|| utc.getUTCDate() !== day
|
|
|| utc.getUTCHours() !== hour
|
|
|| utc.getUTCMinutes() !== minute
|
|
|| utc.getUTCSeconds() !== second
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
return timestampMs;
|
|
}
|
|
|
|
function parseRollingArtifactFile(fileName: string): Phase0RollingArtifactFile | undefined {
|
|
for (const entry of FAMILY_PATTERNS) {
|
|
const match = entry.pattern.exec(fileName);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const tag = match[1] ?? '';
|
|
const timestampMs = parseRollingTagTimestampMs(tag);
|
|
if (typeof timestampMs !== 'number') {
|
|
continue;
|
|
}
|
|
return {
|
|
file_name: fileName,
|
|
family: entry.family,
|
|
tag,
|
|
tag_timestamp_ms: timestampMs,
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function sortByTagTimeDesc(a: { tag_timestamp_ms: number; tag: string }, b: { tag_timestamp_ms: number; tag: string }): number {
|
|
const delta = b.tag_timestamp_ms - a.tag_timestamp_ms;
|
|
if (delta !== 0) {
|
|
return delta;
|
|
}
|
|
return b.tag.localeCompare(a.tag);
|
|
}
|
|
|
|
export function collectRollingPhase0ArtifactFiles(fileNames: string[]): Phase0RollingArtifactFile[] {
|
|
const parsed: Phase0RollingArtifactFile[] = [];
|
|
for (const fileName of fileNames) {
|
|
const row = parseRollingArtifactFile(fileName);
|
|
if (row) {
|
|
parsed.push(row);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function planRollingPhase0ArtifactRetention(
|
|
fileNames: string[],
|
|
keepPerFamily: number,
|
|
): Phase0RollingArtifactRetentionPlan {
|
|
if (!Number.isFinite(keepPerFamily)) {
|
|
throw new Error('keepPerFamily must be a finite integer greater than or equal to 0.');
|
|
}
|
|
if (!Number.isInteger(keepPerFamily)) {
|
|
throw new Error('keepPerFamily must be an integer greater than or equal to 0.');
|
|
}
|
|
if (keepPerFamily < 0) {
|
|
throw new Error('keepPerFamily must be greater than or equal to 0.');
|
|
}
|
|
|
|
const keepLimit = keepPerFamily;
|
|
const parsed = collectRollingPhase0ArtifactFiles(fileNames);
|
|
|
|
const keep: Phase0RollingArtifactFile[] = [];
|
|
const remove: Phase0RollingArtifactFile[] = [];
|
|
const familyRows: Phase0RollingArtifactRetentionPlan['families'] = [];
|
|
|
|
for (const familyPattern of FAMILY_PATTERNS) {
|
|
const family = familyPattern.family;
|
|
const familyFiles = parsed.filter((row) => row.family === family);
|
|
const byTag = new Map<string, { tag_timestamp_ms: number; files: Phase0RollingArtifactFile[] }>();
|
|
|
|
for (const row of familyFiles) {
|
|
const existing = byTag.get(row.tag);
|
|
if (existing) {
|
|
existing.files.push(row);
|
|
existing.tag_timestamp_ms = Math.max(existing.tag_timestamp_ms, row.tag_timestamp_ms);
|
|
} else {
|
|
byTag.set(row.tag, {
|
|
tag_timestamp_ms: row.tag_timestamp_ms,
|
|
files: [row],
|
|
});
|
|
}
|
|
}
|
|
|
|
const sortedTags = [...byTag.entries()]
|
|
.map(([tag, row]) => ({ tag, tag_timestamp_ms: row.tag_timestamp_ms, files: row.files }))
|
|
.sort(sortByTagTimeDesc);
|
|
|
|
const keepTags = new Set(sortedTags.slice(0, keepLimit).map((row) => row.tag));
|
|
|
|
for (const row of familyFiles) {
|
|
if (keepTags.has(row.tag)) {
|
|
keep.push(row);
|
|
} else {
|
|
remove.push(row);
|
|
}
|
|
}
|
|
|
|
familyRows.push({
|
|
family,
|
|
total_tags: sortedTags.length,
|
|
keep_tags: Math.min(sortedTags.length, keepLimit),
|
|
remove_tags: Math.max(0, sortedTags.length - keepLimit),
|
|
});
|
|
}
|
|
|
|
const sortFilesAsc = (a: Phase0RollingArtifactFile, b: Phase0RollingArtifactFile): number => {
|
|
const familyDelta = a.family.localeCompare(b.family);
|
|
if (familyDelta !== 0) {
|
|
return familyDelta;
|
|
}
|
|
const tagDelta = sortByTagTimeDesc(a, b);
|
|
if (tagDelta !== 0) {
|
|
return tagDelta;
|
|
}
|
|
return a.file_name.localeCompare(b.file_name);
|
|
};
|
|
|
|
return {
|
|
keep: [...keep].sort(sortFilesAsc),
|
|
remove: [...remove].sort(sortFilesAsc),
|
|
families: familyRows,
|
|
};
|
|
}
|