Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/archive-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: archive quality
on:
pull_request:
push:
branches: [main]
branches: [main, "feat/**"]

permissions:
contents: read
Expand All @@ -16,6 +16,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
- run: npm run check
- run: npm run validate:network
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ npm run validate:network

The `archive quality` workflow runs on every pull request. It validates 731 metadata records and thumbnails, rejects retired visual themes, checks imported video properties, and samples live `206 Partial Content` responses with retry logic for transient network failures. GitHub Pages deploys only after repository changes are merged.

### Media failure-recovery evidence

The Service Worker now shares one bounded retry primitive for metadata, thumbnails, and remote HLS segments. It retries network rejections plus HTTP **429/5xx** up to 2 times, then preserves the terminal response instead of looping indefinitely. The same reliability module also parses normal and suffix byte ranges, returning `416` with `Content-Range: bytes */<size>` for invalid ranges instead of slicing an invalid segment.

Deterministic failure injection covers `429 → 200`, `503 → 200`, `network failure ×2 → 200`, and persistent `503`. All **3/3 recoverable scenarios recover**, while persistent `503` stops after the retry budget. The repository still validates **731/731** archive records, and two consecutive asset builds produced identical `sw.js` and build-meta hashes.

These fixtures validate Service Worker policy, not real CDN/ISP availability or playback buffering. The benchmark explicitly does not claim production R2/B2 uptime or browser decoder behavior; machine-readable evidence lives under `benchmarks/results/`.

`archive quality` workflow는 모든 pull request에서 실행됩니다. 731개 메타데이터와 썸네일을 검사하고, 폐기한 visual theme의 재사용을 차단하며, import 영상 속성과 실제 `206 Partial Content` 응답을 확인합니다. 일시적인 네트워크 오류에는 재시도를 적용하며 저장소 변경이 병합된 후 GitHub Pages가 배포됩니다.

## Optional Detail-Page Rendition / 선택적 상세 페이지 Rendition
Expand Down
44 changes: 44 additions & 0 deletions benchmarks/media-recovery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"experiment": "source-archive-media-recovery-v1",
"git_sha": "23ff6cfcdbd618dfd3b7974de721a07c79bb99ca",
"generated_at": "2026-09-08T13:24:00.803Z",
"policy": {
"max_retries": 2,
"backoff": "250ms exponential; sleep removed in deterministic benchmark"
},
"recoverable_scenario_success_rate": 1,
"scenarios": [
{
"name": "429-then-ok",
"calls": 2,
"retries": 1,
"terminal_status": 200,
"recovered": true
},
{
"name": "503-then-ok",
"calls": 2,
"retries": 1,
"terminal_status": 200,
"recovered": true
},
{
"name": "network-twice-then-ok",
"calls": 3,
"retries": 2,
"terminal_status": 200,
"recovered": true
},
{
"name": "persistent-503",
"calls": 3,
"retries": 2,
"terminal_status": 503,
"recovered": false
}
],
"limitations": [
"Failure injection validates retry/range policy without live CDN traffic.",
"It does not measure real buffering, ISP loss, R2/B2 availability, or browser decoder failures."
]
}
2 changes: 1 addition & 1 deletion data/build-meta.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
window.SOURCE_ARCHIVE_BUILD={"version":"c3668829f8b2","items":731};
window.SOURCE_ARCHIVE_BUILD={"version":"b38bba31bd4b","items":731};
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"build": "node scripts/build-web-assets.mjs",
"validate": "node scripts/validate-archive.mjs",
"validate:network": "node scripts/validate-archive.mjs --network",
"check": "npm run build && git diff --exit-code -- data/search-index.json data/build-meta.js sw.js && npm run validate"
"test": "node --test tests/*.test.mjs",
"benchmark:reliability": "node scripts/benchmark-reliability.mjs",
"check": "npm run test && npm run benchmark:reliability && npm run build && git diff --exit-code -- data/search-index.json data/build-meta.js sw.js && npm run validate"
}
}
49 changes: 49 additions & 0 deletions scripts/benchmark-reliability.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { execSync } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';

await import('../sw-reliability.js');
const { fetchWithRetry } = globalThis.SourceArchiveReliability;

const scenarios = [
['429-then-ok', [429, 200]],
['503-then-ok', [503, 200]],
['network-twice-then-ok', ['network', 'network', 200]],
['persistent-503', [503, 503, 503]],
];

const results = [];
for (const [name, sequence] of scenarios) {
const statuses = [...sequence];
let calls = 0;
try {
const result = await fetchWithRetry(name, {
fetchImpl: async () => {
calls += 1;
const next = statuses.shift();
if (next === 'network') throw new TypeError('injected network failure');
return { status: next };
},
sleep: async () => {},
});
results.push({ name, calls, retries: result.retries, terminal_status: result.response.status, recovered: result.recovered && result.response.status === 200 });
} catch (error) {
results.push({ name, calls, retries: calls - 1, terminal_status: 'network-error', recovered: false, error: error.name });
}
}

const recoverable = results.filter((item) => item.name !== 'persistent-503');
const payload = {
experiment: 'source-archive-media-recovery-v1',
git_sha: execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(),
generated_at: new Date().toISOString(),
policy: { max_retries: 2, backoff: '250ms exponential; sleep removed in deterministic benchmark' },
recoverable_scenario_success_rate: recoverable.filter((item) => item.recovered).length / recoverable.length,
scenarios: results,
limitations: [
'Failure injection validates retry/range policy without live CDN traffic.',
'It does not measure real buffering, ISP loss, R2/B2 availability, or browser decoder failures.',
],
};
mkdirSync('benchmarks', { recursive: true });
writeFileSync('benchmarks/media-recovery.json', `${JSON.stringify(payload, null, 2)}\n`);
console.log(JSON.stringify(payload, null, 2));
54 changes: 54 additions & 0 deletions sw-reliability.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
(function attachSourceArchiveReliability(root) {
const retryableStatus = (status) => status === 429 || status >= 500;

const backoffMs = (attempt, baseMs = 250, maxMs = 2000) =>
Math.min(maxMs, baseMs * (2 ** attempt));

async function fetchWithRetry(request, options = {}) {
const fetchImpl = options.fetchImpl || root.fetch.bind(root);
const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2;
const baseMs = options.baseMs ?? 250;
let retries = 0;
let lastError = null;

for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetchImpl(request);
if (!retryableStatus(response.status) || attempt === maxRetries) {
return { response, retries, recovered: retries > 0, errorCategory: null };
}
retries += 1;
} catch (error) {
lastError = error;
if (attempt === maxRetries) throw error;
retries += 1;
}
await sleep(backoffMs(attempt, baseMs));
}
throw lastError || new Error('retry budget exhausted');
}

function parseByteRange(header, length) {
if (!header || !Number.isInteger(length) || length <= 0) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match || (!match[1] && !match[2])) return null;

let start;
let end;
if (!match[1]) {
const suffixLength = Number(match[2]);
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return null;
start = Math.max(0, length - suffixLength);
end = length - 1;
} else {
start = Number(match[1]);
end = match[2] ? Number(match[2]) : length - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= length || end < start) return null;
end = Math.min(end, length - 1);
}
return { start, end };
}

root.SourceArchiveReliability = { backoffMs, fetchWithRetry, parseByteRange, retryableStatus };
}(typeof self !== 'undefined' ? self : globalThis));
13 changes: 5 additions & 8 deletions sw.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions tests/sw-reliability.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';

await import('../sw-reliability.js');
const { fetchWithRetry, parseByteRange } = globalThis.SourceArchiveReliability;

test('retries a transient network error and reports recovery', async () => {
let calls = 0;
const result = await fetchWithRetry('asset', {
fetchImpl: async () => {
calls += 1;
if (calls < 3) throw new TypeError('network interrupted');
return { status: 200 };
},
sleep: async () => {},
});
assert.equal(calls, 3);
assert.equal(result.retries, 2);
assert.equal(result.recovered, true);
});

test('retries 429 and 5xx but preserves terminal response', async () => {
const statuses = [429, 503, 200];
const result = await fetchWithRetry('metadata', {
fetchImpl: async () => ({ status: statuses.shift() }),
sleep: async () => {},
});
assert.equal(result.response.status, 200);
assert.equal(result.retries, 2);
});

test('parses explicit and suffix byte ranges and rejects invalid ranges', () => {
assert.deepEqual(parseByteRange('bytes=10-19', 100), { start: 10, end: 19 });
assert.deepEqual(parseByteRange('bytes=-10', 100), { start: 90, end: 99 });
assert.equal(parseByteRange('bytes=120-130', 100), null);
assert.equal(parseByteRange('bytes=20-10', 100), null);
});
Loading