diff --git a/.github/workflows/archive-quality.yml b/.github/workflows/archive-quality.yml index 8748383..f22a491 100644 --- a/.github/workflows/archive-quality.yml +++ b/.github/workflows/archive-quality.yml @@ -3,7 +3,7 @@ name: archive quality on: pull_request: push: - branches: [main] + branches: [main, "feat/**"] permissions: contents: read @@ -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 diff --git a/README.md b/README.md index 50c48a9..a5b35e5 100644 --- a/README.md +++ b/README.md @@ -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 */` 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 diff --git a/benchmarks/media-recovery.json b/benchmarks/media-recovery.json new file mode 100644 index 0000000..4d121df --- /dev/null +++ b/benchmarks/media-recovery.json @@ -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." + ] +} diff --git a/data/build-meta.js b/data/build-meta.js index 3e1653f..5fa5e84 100644 --- a/data/build-meta.js +++ b/data/build-meta.js @@ -1 +1 @@ -window.SOURCE_ARCHIVE_BUILD={"version":"c3668829f8b2","items":731}; +window.SOURCE_ARCHIVE_BUILD={"version":"b38bba31bd4b","items":731}; diff --git a/package.json b/package.json index 7990831..ec48874 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/benchmark-reliability.mjs b/scripts/benchmark-reliability.mjs new file mode 100644 index 0000000..2817e20 --- /dev/null +++ b/scripts/benchmark-reliability.mjs @@ -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)); diff --git a/sw-reliability.js b/sw-reliability.js new file mode 100644 index 0000000..29bbc24 --- /dev/null +++ b/sw-reliability.js @@ -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)); diff --git a/sw.js b/sw.js index d4efd21..2872fff 100644 --- a/sw.js +++ b/sw.js @@ -1,7 +1,7 @@ -importScripts('data/hls-boot-pack-manifest.js'); +importScripts('sw-reliability.js','data/hls-boot-pack-manifest.js'); const VERSION="source-archive-hlsboot-v1"; const BOOT_CACHE="source-archive-hlsboot-packs-v1"; -const PRECACHE=["./","./index.html","./search-worker.js","./performance-dashboard.js","./data/source-library-data.js","./data/source-library-youtube-data.js","./data/search-index.json","./data/build-meta.js","./data/preview-manifest.js","./data/hls-boot-pack-manifest.js"]; +const PRECACHE=["./","./index.html","./sw-reliability.js","./search-worker.js","./performance-dashboard.js","./data/source-library-data.js","./data/source-library-youtube-data.js","./data/search-index.json","./data/build-meta.js","./data/preview-manifest.js","./data/hls-boot-pack-manifest.js"]; const ROOT=new URL('./',self.location).pathname; const REMOTE_HLS='https://source-media.oosu.dev/hls/'; const bootMemory=new Map(); @@ -11,7 +11,7 @@ async function loadBootPack(pack){if(bootMemory.has(pack))return bootMemory.get( async function preloadBootPacks(){const packs=[...new Set(Object.values(self.SOURCE_ARCHIVE_HLS_BOOT_PACKS||{}))];let next=0;await Promise.all(Array.from({length:4},async()=>{while(next`#EXTINF:2.000000,\nseg_${String(index).padStart(3,'0')}.ts`).join('\n');return `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n${segments}\n#EXT-X-ENDLIST\n`} -function bootResponse(bytes,request){const range=request.headers.get('range');if(!range)return new Response(bytes,{headers:{'Content-Type':'video/mp2t','Content-Length':String(bytes.byteLength),'Cache-Control':'public, max-age=31536000, immutable'}});const match=/bytes=(\d*)-(\d*)/.exec(range);if(!match)return new Response(null,{status:416});const start=match[1]?Number(match[1]):0,end=match[2]?Math.min(Number(match[2]),bytes.byteLength-1):bytes.byteLength-1;return new Response(bytes.slice(start,end+1),{status:206,headers:{'Content-Type':'video/mp2t','Content-Range':`bytes ${start}-${end}/${bytes.byteLength}`,'Accept-Ranges':'bytes','Content-Length':String(end-start+1)}})} +function bootResponse(bytes,request){const range=request.headers.get('range');if(!range)return new Response(bytes,{headers:{'Content-Type':'video/mp2t','Content-Length':String(bytes.byteLength),'Cache-Control':'public, max-age=31536000, immutable'}});const parsed=self.SourceArchiveReliability.parseByteRange(range,bytes.byteLength);if(!parsed)return new Response(null,{status:416,headers:{'Content-Range':`bytes */${bytes.byteLength}`}});const {start,end}=parsed;return new Response(bytes.slice(start,end+1),{status:206,headers:{'Content-Type':'video/mp2t','Content-Range':`bytes ${start}-${end}/${bytes.byteLength}`,'Accept-Ranges':'bytes','Content-Length':String(end-start+1)}})} self.addEventListener('message',event=>{if(event.data==='SKIP_WAITING')self.skipWaiting();if(event.data?.type==='preload-hls-boot')event.waitUntil(preloadBootPacks());if(event.data?.type==='hls-boot-status')event.source?.postMessage({type:'hls-boot-ready'})}); self.addEventListener('install',event=>event.waitUntil(caches.open(VERSION).then(cache=>cache.addAll(PRECACHE)).then(()=>self.skipWaiting()))); self.addEventListener('activate',event=>event.waitUntil(caches.keys().then(keys=>Promise.all(keys.filter(key=>key!==VERSION&&key!==BOOT_CACHE).map(key=>caches.delete(key)))).then(()=>self.clients.claim()).then(()=>self.clients.matchAll({type:'window'})).then(clients=>clients.forEach(client=>client.postMessage({type:'hls-boot-ready'}))))); @@ -25,13 +25,10 @@ self.addEventListener('fetch',event=>{ if(rest==='master.m3u8')return new Response(hlsMaster(),{headers:{'Content-Type':'application/vnd.apple.mpegurl'}}); if(rest==='vlow/index.m3u8'||rest==='vhigh/index.m3u8')return new Response(hlsPlaylist(),{headers:{'Content-Type':'application/vnd.apple.mpegurl'}}); if(rest==='vlow/seg_000.ts'){const pack=self.SOURCE_ARCHIVE_HLS_BOOT_PACKS?.[clip],bytes=pack&&(await loadBootPack(pack)).get(`${clip}.ts`);if(bytes)return bootResponse(bytes,event.request)} - return fetch(`${REMOTE_HLS}${clip}/${rest}`,{headers:event.request.headers}); + return self.SourceArchiveReliability.fetchWithRetry(`${REMOTE_HLS}${clip}/${rest}`,{fetchImpl:(request)=>fetch(request,{headers:event.request.headers})}).then(result=>result.response); })());return; } - const retry=(request,attempt=0)=>fetch(request).then(response=>{ - if((response.status===429||response.status>=500)&&attempt<2)return new Promise(resolve=>setTimeout(resolve,250*(attempt+1))).then(()=>retry(request,attempt+1)); - return response; - }); + const retry=request=>self.SourceArchiveReliability.fetchWithRetry(request).then(result=>result.response); const isMetadata=url.pathname.includes('/data/')||url.pathname.endsWith('/index.html')||url.pathname.endsWith('/'); if(isMetadata){event.respondWith(retry(event.request).then(async response=>{if(response.ok){const copy=response.clone();await caches.open(VERSION).then(cache=>cache.put(event.request,copy))}else{const cached=await caches.match(event.request);if(cached)return cached}return response}).catch(()=>caches.match(event.request)));return} if(url.pathname.includes('/assets/thumbs/')||url.pathname.includes('/assets/thumbs-low/')||url.pathname.includes('/assets/thumbs-medium/')||url.pathname.includes('/assets/thumbs-360/')){event.respondWith(caches.open(VERSION).then(async cache=>{ diff --git a/tests/sw-reliability.test.mjs b/tests/sw-reliability.test.mjs new file mode 100644 index 0000000..2b8ea72 --- /dev/null +++ b/tests/sw-reliability.test.mjs @@ -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); +});