diff --git a/test/usage.test.js b/test/usage.test.js index 4b2e4c9..c11b48a 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -95,3 +95,32 @@ test('usage log: writes are batched (one read and one write for several calls), assert.equal(file.slice(old.length).trim().split('\n').length, 2); assert.deepEqual(calls, [`GET /repos/Fizzl13/usage-log/contents/${path}`, `PUT /repos/Fizzl13/usage-log/contents/${path}`]); }); + +test('usage log: the next write to the same day file skips the read; a file changed elsewhere is read again', async () => { + const crypto = require('node:crypto'); + const { createUsageLog } = require('../usage-log'); + const shaOf = (text) => { const b = Buffer.from(text); return crypto.createHash('sha1').update(`blob ${b.length}\0`).update(b).digest('hex'); }; + let file = null; + const calls = []; + // Like GitHub: a write must name the sha of the file it replaces. + const fetchFn = async (url, opts = {}) => { + const method = opts.method || 'GET'; + calls.push(method); + if (method === 'GET') return file === null ? new Response('{}', { status: 404 }) : new Response(file); + const body = JSON.parse(opts.body); + if (file !== null && body.sha !== shaOf(file)) return new Response('{}', { status: 409 }); + file = Buffer.from(body.content, 'base64').toString('utf8'); + return Response.json({}, { status: 201 }); + }; + const log = createUsageLog({ service: 'plaintext', env: { USAGE_LOG_TOKEN: 't' }, fetchFn, now: () => new Date('2026-10-14T10:00:00Z'), log: { warn() {}, error() {} } }); + log.record({ route: 'check-wallet', status: 402 }); + await log.flush(); + log.record({ route: 'explain', status: 200 }); + await log.flush(); + assert.deepEqual(calls, ['GET', 'PUT', 'PUT'], 'one read, then writes only'); + file += '{"t":"2026-10-14T10:00:01.000Z","service":"plaintext","route":"other"}\n'; // another writer appended + log.record({ route: 'explain', status: 402 }); + await log.flush(); + assert.deepEqual(calls.slice(3), ['PUT', 'GET', 'PUT']); + assert.deepEqual(file.trim().split('\n').map((l) => JSON.parse(l).route), ['check-wallet', 'explain', 'other', 'explain'], 'no line lost'); +}); diff --git a/usage-log.js b/usage-log.js index 0e6ed33..a7e56b8 100644 --- a/usage-log.js +++ b/usage-log.js @@ -170,29 +170,43 @@ function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch 'user-agent': `usage-log/${service}`, }; + // The day file as this service last wrote it. Only this service writes its own files, so the next append to + // the same file skips the read (half the bandwidth of a write); if the file did change (a deploy's old and new + // instance both writing), GitHub refuses the stale sha with a 409 and the file is read again. + let last = null; // { path, sha, text } + // Appends lines to one file: read (for the sha), write, retry on a race. async function append(path, lines) { for (let attempt = 0; attempt < 4; attempt++) { const url = `${API}/repos/${repo}/contents/${path}`; - // The raw file: the JSON form leaves out the content of files over 1 MB, which would then be overwritten - // with only the new lines. The sha GitHub wants back is the git blob sha of exactly these bytes. - const current = await fetchFn(url, { headers: { ...headers, accept: 'application/vnd.github.raw+json' } }); let sha; let text = ''; - if (current.status === 200) { - const raw = Buffer.from(await current.arrayBuffer()); - sha = blobSha(raw); - text = raw.toString('utf8'); - } else if (current.status !== 404) { - throw new Error(`read ${path}: HTTP ${current.status}`); + if (attempt === 0 && last && last.path === path) { + ({ sha, text } = last); + } else { + // The raw file: the JSON form leaves out the content of files over 1 MB, which would then be overwritten + // with only the new lines. The sha GitHub wants back is the git blob sha of exactly these bytes. + const current = await fetchFn(url, { headers: { ...headers, accept: 'application/vnd.github.raw+json' } }); + if (current.status === 200) { + const raw = Buffer.from(await current.arrayBuffer()); + sha = blobSha(raw); + text = raw.toString('utf8'); + } else if (current.status !== 404) { + throw new Error(`read ${path}: HTTP ${current.status}`); + } } + const content = Buffer.from(text + lines.join('\n') + '\n'); const body = { message: `${service}: ${lines.length} call${lines.length === 1 ? '' : 's'}`, - content: Buffer.from(text + lines.join('\n') + '\n').toString('base64'), + content: content.toString('base64'), ...(sha ? { sha } : {}), }; const put = await fetchFn(url, { method: 'PUT', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body) }); - if (put.status === 200 || put.status === 201) return; + if (put.status === 200 || put.status === 201) { + last = { path, sha: blobSha(content), text: content.toString('utf8') }; + return; + } + last = null; if (put.status !== 409 && put.status !== 422) throw new Error(`write ${path}: HTTP ${put.status}`); await new Promise((r) => setTimeout(r, 250 * (attempt + 1))); }