Skip to content

Commit 4f3af27

Browse files
committed
feat: wire processors through initProcessorFromDump and add S3 cache backup/restore script
1 parent c8d5e3c commit 4f3af27

6 files changed

Lines changed: 399 additions & 8 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"process:test": "sqd process:test",
2929
"process": "sqd process",
3030
"generate:validations": "ts-node --require ./scripts/register-js-ts.js --require tsconfig-paths/register scripts/generate-validations.ts",
31+
"cache:backup": "ts-node scripts/cache-s3.ts backup",
32+
"cache:restore": "ts-node scripts/cache-s3.ts restore",
33+
"cache:list": "ts-node scripts/cache-s3.ts list",
3134
"log:processing-times": "ts-node --require tsconfig-paths/register scripts/check-processing-times.ts",
3235
"postdeploy": "sh -c 'pnpm run log:processing-times $1 && pnpm run generate:validations $1 $2' --",
3336
"lint": "eslint \"src/**/*.ts\"",

scripts/cache-s3.ts

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
/**
2+
* Back up the local portal/rpc caches to S3 and restore them back.
3+
*
4+
* The caches (`.portal-cache`, `.rpc-cache`) are per-processor SQLite files
5+
* named `<processor>.sqlite`. They only accelerate the historic-load phase
6+
* and are append-only, so S3 is just a durable backup / share point for the
7+
* dev box that generates DB dumps — NOT a deployment dependency.
8+
*
9+
* Everything goes through the `aws` CLI (metadata via `s3api` with JSON
10+
* output, transfers via `s3 cp`). That's deliberate: the CLI resolves
11+
* whatever credential mechanism is active in your shell — including custom
12+
* `aws login` setups the JS SDK can't understand — so if `aws s3 ls` works
13+
* in your console, this tool works too. No `--profile` needed unless you
14+
* want to override the ambient config.
15+
*
16+
* Layout:
17+
* .portal-cache/<processor>.sqlite <-> s3://origin-squid/cache/portal/<processor>.sqlite
18+
* .rpc-cache/<processor>.sqlite <-> s3://origin-squid/cache/rpc/<processor>.sqlite
19+
*
20+
* Usage:
21+
* tsx scripts/cache-s3.ts backup [processor] [--cache portal|rpc|all] [--profile <aws>] [-y] [--dry-run]
22+
* tsx scripts/cache-s3.ts restore [processor] [--cache portal|rpc|all] [--profile <aws>] [-y] [--force] [--dry-run]
23+
* tsx scripts/cache-s3.ts list [processor] [--cache portal|rpc|all] [--profile <aws>]
24+
*
25+
* processor e.g. oeth-processor. Omit to operate on every cache found
26+
* (locally for backup, in S3 for restore).
27+
* --cache which cache(s) to act on (default: all).
28+
* --profile AWS CLI profile to pass through (default: ambient config).
29+
* -y/--yes skip the overwrite confirmation (restore) and the
30+
* running-processor warning (backup).
31+
* --force restore: overwrite local even when it looks newer than S3.
32+
* --dry-run print what would happen, transfer nothing.
33+
*
34+
* Restore NEVER overwrites a local cache without confirmation — the local
35+
* copy is almost always more up to date than S3. In a non-interactive shell
36+
* it skips existing files unless -y/--force is passed.
37+
*/
38+
import { spawn } from 'child_process'
39+
import 'dotenv/config'
40+
import * as fs from 'fs'
41+
import * as path from 'path'
42+
import { createInterface } from 'readline'
43+
44+
const BUCKET = 'origin-squid'
45+
46+
interface CacheKind {
47+
name: 'portal' | 'rpc'
48+
dir: string
49+
prefix: string // s3 prefix, e.g. cache/portal/
50+
}
51+
52+
const CACHES: Record<'portal' | 'rpc', CacheKind> = {
53+
portal: { name: 'portal', dir: process.env.PORTAL_CACHE_DIR ?? '.portal-cache', prefix: 'cache/portal/' },
54+
rpc: { name: 'rpc', dir: process.env.RPC_CACHE_DIR ?? '.rpc-cache', prefix: 'cache/rpc/' },
55+
}
56+
57+
interface Args {
58+
command: 'backup' | 'restore' | 'list'
59+
processor?: string
60+
caches: CacheKind[]
61+
awsProfile?: string
62+
assumeYes: boolean
63+
force: boolean
64+
dryRun: boolean
65+
}
66+
67+
function parseArgs(): Args {
68+
const argv = process.argv.slice(2)
69+
const command = argv[0] as Args['command']
70+
if (!['backup', 'restore', 'list'].includes(command)) {
71+
console.error(
72+
'Usage: cache-s3.ts <backup|restore|list> [processor] [--cache portal|rpc|all] [--profile <aws>] [-y] [--force] [--dry-run]',
73+
)
74+
process.exit(1)
75+
}
76+
77+
let processor: string | undefined
78+
let cacheSel = 'all'
79+
let awsProfile: string | undefined
80+
let assumeYes = false
81+
let force = false
82+
let dryRun = false
83+
84+
for (let i = 1; i < argv.length; i++) {
85+
const a = argv[i]
86+
if (a === '--cache' && argv[i + 1]) {
87+
cacheSel = argv[++i]
88+
} else if (a === '--profile' && argv[i + 1]) {
89+
awsProfile = argv[++i]
90+
} else if (a === '-y' || a === '--yes') {
91+
assumeYes = true
92+
} else if (a === '--force') {
93+
force = true
94+
} else if (a === '--dry-run') {
95+
dryRun = true
96+
} else if (a === '--') {
97+
// pnpm/npm forward a bare `--` separator; ignore it.
98+
continue
99+
} else if (!a.startsWith('-')) {
100+
processor = a
101+
} else {
102+
console.error(`Unknown argument: ${a}`)
103+
process.exit(1)
104+
}
105+
}
106+
107+
if (cacheSel !== 'portal' && cacheSel !== 'rpc' && cacheSel !== 'all') {
108+
console.error(`--cache must be portal, rpc, or all (got ${cacheSel})`)
109+
process.exit(1)
110+
}
111+
const caches = cacheSel === 'all' ? [CACHES.portal, CACHES.rpc] : [CACHES[cacheSel as 'portal' | 'rpc']]
112+
113+
return { command, processor, caches, awsProfile, assumeYes, force, dryRun }
114+
}
115+
116+
function fmtBytes(n: number): string {
117+
if (n < 1024) return `${n} B`
118+
const units = ['KB', 'MB', 'GB', 'TB']
119+
let v = n / 1024
120+
let i = 0
121+
while (v >= 1024 && i < units.length - 1) {
122+
v /= 1024
123+
i++
124+
}
125+
return `${v.toFixed(1)} ${units[i]}`
126+
}
127+
128+
function fmtDate(d?: Date): string {
129+
return d ? d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—'
130+
}
131+
132+
/** Prepend the `aws` argv with `--profile` when one was requested. */
133+
function withProfile(args: string[], awsProfile?: string): string[] {
134+
return awsProfile ? [...args, '--profile', awsProfile] : args
135+
}
136+
137+
interface AwsResult {
138+
code: number
139+
stdout: string
140+
stderr: string
141+
}
142+
143+
/** Run the `aws` CLI, capturing stdout/stderr (or streaming when `inherit`). */
144+
function runAws(args: string[], opts: { inherit?: boolean } = {}): Promise<AwsResult> {
145+
return new Promise((resolve, reject) => {
146+
const child = spawn('aws', args, {
147+
stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
148+
})
149+
let stdout = ''
150+
let stderr = ''
151+
if (!opts.inherit) {
152+
child.stdout!.on('data', (d: Buffer) => (stdout += d.toString()))
153+
child.stderr!.on('data', (d: Buffer) => (stderr += d.toString()))
154+
}
155+
child.on('error', (err: any) => {
156+
if (err?.code === 'ENOENT') {
157+
reject(new Error('The AWS CLI (`aws`) is required but was not found on PATH.'))
158+
} else {
159+
reject(err)
160+
}
161+
})
162+
child.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }))
163+
})
164+
}
165+
166+
interface RemoteInfo {
167+
size: number
168+
lastModified?: Date
169+
}
170+
171+
async function headRemote(key: string, awsProfile?: string): Promise<RemoteInfo | null> {
172+
const { code, stdout, stderr } = await runAws(
173+
withProfile(['s3api', 'head-object', '--bucket', BUCKET, '--key', key, '--output', 'json'], awsProfile),
174+
)
175+
if (code !== 0) {
176+
if (/\b404\b|Not Found|NoSuchKey/i.test(stderr)) return null
177+
throw new Error(`aws s3api head-object failed for ${key}: ${stderr.trim()}`)
178+
}
179+
const j = JSON.parse(stdout)
180+
return { size: j.ContentLength ?? 0, lastModified: j.LastModified ? new Date(j.LastModified) : undefined }
181+
}
182+
183+
async function listRemoteProcessors(cache: CacheKind, awsProfile?: string): Promise<string[]> {
184+
const { code, stdout, stderr } = await runAws(
185+
withProfile(['s3api', 'list-objects-v2', '--bucket', BUCKET, '--prefix', cache.prefix, '--output', 'json'], awsProfile),
186+
)
187+
if (code !== 0) {
188+
throw new Error(`aws s3api list-objects-v2 failed: ${stderr.trim()}`)
189+
}
190+
// An empty prefix yields empty stdout with exit 0.
191+
const j = stdout.trim() ? JSON.parse(stdout) : {}
192+
const out: string[] = []
193+
for (const obj of j.Contents ?? []) {
194+
const base = (obj.Key as string)?.slice(cache.prefix.length)
195+
if (base && base.endsWith('.sqlite')) out.push(base.replace(/\.sqlite$/, ''))
196+
}
197+
return out
198+
}
199+
200+
function listLocalProcessors(cache: CacheKind): string[] {
201+
if (!fs.existsSync(cache.dir)) return []
202+
return fs
203+
.readdirSync(cache.dir)
204+
.filter((f) => f.endsWith('.sqlite'))
205+
.map((f) => f.replace(/\.sqlite$/, ''))
206+
}
207+
208+
async function promptYesNo(message: string): Promise<boolean> {
209+
if (!process.stdin.isTTY) return false
210+
const rl = createInterface({ input: process.stdin, output: process.stdout })
211+
try {
212+
const answer: string = await new Promise((resolve) => rl.question(message, resolve))
213+
const a = answer.trim().toLowerCase()
214+
return a === 'y' || a === 'yes'
215+
} finally {
216+
rl.close()
217+
}
218+
}
219+
220+
async function awsCp(src: string, dst: string, awsProfile?: string): Promise<void> {
221+
const args = withProfile(['s3', 'cp', src, dst], awsProfile)
222+
console.log(` aws ${args.join(' ')}`)
223+
const { code } = await runAws(args, { inherit: true })
224+
if (code !== 0) throw new Error(`aws s3 cp failed (exit ${code})`)
225+
}
226+
227+
/**
228+
* Collapse the WAL into the main .sqlite so a single-file upload is
229+
* consistent. Returns false (and warns) if a -wal remains non-empty after
230+
* the checkpoint, which means a processor is probably still writing.
231+
*/
232+
function checkpointWal(filePath: string): boolean {
233+
// eslint-disable-next-line @typescript-eslint/no-require-imports
234+
const Database = require('better-sqlite3')
235+
const db = new Database(filePath)
236+
try {
237+
db.pragma('wal_checkpoint(TRUNCATE)')
238+
} finally {
239+
db.close()
240+
}
241+
const wal = `${filePath}-wal`
242+
if (fs.existsSync(wal) && fs.statSync(wal).size > 0) return false
243+
return true
244+
}
245+
246+
function s3Url(cache: CacheKind, processor: string): string {
247+
return `s3://${BUCKET}/${cache.prefix}${processor}.sqlite`
248+
}
249+
250+
async function resolveProcessors(args: Args, cache: CacheKind): Promise<string[]> {
251+
if (args.processor) return [args.processor]
252+
return args.command === 'backup' ? listLocalProcessors(cache) : await listRemoteProcessors(cache, args.awsProfile)
253+
}
254+
255+
async function runBackup(args: Args) {
256+
for (const cache of args.caches) {
257+
const processors = await resolveProcessors(args, cache)
258+
if (processors.length === 0) {
259+
console.log(`[${cache.name}] no local caches found in ${cache.dir}`)
260+
continue
261+
}
262+
for (const processor of processors) {
263+
const localPath = path.join(cache.dir, `${processor}.sqlite`)
264+
if (!fs.existsSync(localPath)) {
265+
console.warn(`[${cache.name}] ${processor}: local file missing (${localPath}); skipping`)
266+
continue
267+
}
268+
269+
console.log(`\n[${cache.name}] ${processor}`)
270+
console.log(` local: ${localPath} (${fmtBytes(fs.statSync(localPath).size)})`)
271+
const remote = await headRemote(`${cache.prefix}${processor}.sqlite`, args.awsProfile)
272+
console.log(` remote: ${remote ? `${fmtBytes(remote.size)} @ ${fmtDate(remote.lastModified)}` : '(none)'}`)
273+
274+
if (args.dryRun) {
275+
console.log(' dry-run: would checkpoint WAL and upload')
276+
continue
277+
}
278+
279+
const clean = checkpointWal(localPath)
280+
if (!clean) {
281+
console.warn(' WARNING: WAL still has unflushed data after checkpoint — a processor may be running.')
282+
console.warn(' The upload could miss recent writes. Stop the processor for a clean backup.')
283+
if (!args.assumeYes && !(await promptYesNo(' Upload anyway? [y/N]: '))) {
284+
console.log(' skipped')
285+
continue
286+
}
287+
}
288+
await awsCp(localPath, s3Url(cache, processor), args.awsProfile)
289+
console.log(' uploaded')
290+
}
291+
}
292+
}
293+
294+
async function runRestore(args: Args) {
295+
for (const cache of args.caches) {
296+
const processors = await resolveProcessors(args, cache)
297+
if (processors.length === 0) {
298+
console.log(`[${cache.name}] nothing to restore`)
299+
continue
300+
}
301+
for (const processor of processors) {
302+
const localPath = path.join(cache.dir, `${processor}.sqlite`)
303+
const remote = await headRemote(`${cache.prefix}${processor}.sqlite`, args.awsProfile)
304+
if (!remote) {
305+
console.warn(`[${cache.name}] ${processor}: not found in S3; skipping`)
306+
continue
307+
}
308+
309+
console.log(`\n[${cache.name}] ${processor}`)
310+
console.log(` remote: ${fmtBytes(remote.size)} @ ${fmtDate(remote.lastModified)}`)
311+
312+
const localExists = fs.existsSync(localPath)
313+
if (localExists) {
314+
const stat = fs.statSync(localPath)
315+
const localNewer = stat.mtime > (remote.lastModified ?? new Date(0))
316+
console.log(` local: ${localPath} (${fmtBytes(stat.size)} @ ${fmtDate(stat.mtime)})`)
317+
console.log(` ${localNewer ? 'Local looks NEWER than S3.' : 'S3 looks newer than local.'}`)
318+
319+
// Local is the source of truth — never clobber it silently.
320+
if (!args.force && !args.assumeYes) {
321+
if (!process.stdin.isTTY) {
322+
console.warn(' non-interactive shell: refusing to overwrite local. Pass -y or --force to override. Skipping.')
323+
continue
324+
}
325+
const ok = await promptYesNo(` Overwrite local cache with the S3 copy? [y/N]: `)
326+
if (!ok) {
327+
console.log(' kept local')
328+
continue
329+
}
330+
}
331+
}
332+
333+
if (args.dryRun) {
334+
console.log(` dry-run: would download into ${localPath}`)
335+
continue
336+
}
337+
338+
fs.mkdirSync(cache.dir, { recursive: true })
339+
await awsCp(s3Url(cache, processor), localPath, args.awsProfile)
340+
// Drop any stale WAL/SHM so the freshly downloaded db isn't shadowed
341+
// by leftover journal files from the previous local copy.
342+
for (const sfx of ['-wal', '-shm']) {
343+
const p = `${localPath}${sfx}`
344+
if (fs.existsSync(p)) fs.rmSync(p)
345+
}
346+
console.log(' restored')
347+
}
348+
}
349+
}
350+
351+
async function runList(args: Args) {
352+
for (const cache of args.caches) {
353+
console.log(`\n[${cache.name}] s3://${BUCKET}/${cache.prefix}`)
354+
const remoteProcs = args.processor ? [args.processor] : await listRemoteProcessors(cache, args.awsProfile)
355+
const localProcs = new Set(listLocalProcessors(cache))
356+
const all = new Set([...remoteProcs, ...localProcs])
357+
if (all.size === 0) {
358+
console.log(' (empty)')
359+
continue
360+
}
361+
for (const processor of [...all].sort()) {
362+
const remote = await headRemote(`${cache.prefix}${processor}.sqlite`, args.awsProfile)
363+
const localPath = path.join(cache.dir, `${processor}.sqlite`)
364+
const local = fs.existsSync(localPath) ? fs.statSync(localPath) : null
365+
console.log(
366+
` ${processor.padEnd(20)} ` +
367+
`s3: ${(remote ? `${fmtBytes(remote.size)} @ ${fmtDate(remote.lastModified)}` : '—').padEnd(34)} ` +
368+
`local: ${local ? `${fmtBytes(local.size)} @ ${fmtDate(local.mtime)}` : '—'}`,
369+
)
370+
}
371+
}
372+
}
373+
374+
async function main() {
375+
const args = parseArgs()
376+
if (args.command === 'backup') await runBackup(args)
377+
else if (args.command === 'restore') await runRestore(args)
378+
else await runList(args)
379+
}
380+
381+
main().catch((err) => {
382+
console.error(err)
383+
process.exit(1)
384+
})

0 commit comments

Comments
 (0)