-
-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(api): public compactPad API + bin/compactPad CLI over existing Cleanup #7567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JohnMcLear
merged 9 commits into
ether:develop
from
JohnMcLear:feat/compact-pad-cli-6194
May 1, 2026
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
b7a180e
feat(pad): compactHistory() + compactPad CLI for DB-size reclaim
JohnMcLear 8a9e79c
fix(compact): delegate to copyPadWithoutHistory via temp-pad swap
JohnMcLear 4d37c1a
test(6194): match the head<=1 post-compact contract
JohnMcLear 2559c8b
refactor(6194): wrap existing Cleanup instead of duplicating it
JohnMcLear 48b89f4
test(6194): assert content markers, not byte-exact atext
JohnMcLear 655fe42
fix(6194): correct API param + document compactPad in http_api docs
JohnMcLear 34bb9be
feat(6194): add bin/compactAllPads for per-instance bulk compaction
JohnMcLear 307e590
test(6194): cover the bin/compactAllPads loop logic
JohnMcLear 5fb8039
fix(6194): address Qodo review — gate, integer check, SSL
JohnMcLear File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| 'use strict'; | ||
|
|
||
| /* | ||
| * Compact every pad on the instance to reclaim database space. | ||
| * | ||
| * Usage: | ||
| * node bin/compactAllPads.js # collapse all history on every pad | ||
| * node bin/compactAllPads.js --keep N # keep last N revisions per pad | ||
| * node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes | ||
| * | ||
| * Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is | ||
| * deliberately no instance-wide HTTP endpoint, because doing this over a | ||
| * single request would mean one giant response and a long-held connection. | ||
| * Per-pad failures don't stop the run; they're logged and counted, and the | ||
| * exit code reflects whether anything failed. | ||
| * | ||
| * Destructive — `getEtherpad`-export anything you can't afford to lose | ||
| * before running. | ||
| * | ||
| * Issue #6194: per-instance bulk compaction. The per-pad `bin/compactPad` | ||
| * is the right tool when you know which pad is fat; this is the right tool | ||
| * when you want to reclaim space across the whole instance. | ||
| */ | ||
| import path from 'node:path'; | ||
| import fs from 'node:fs'; | ||
| import process from 'node:process'; | ||
| import axios from 'axios'; | ||
|
|
||
| export type CompactAllOpts = { | ||
| keepRevisions: number | null; | ||
| dryRun: boolean; | ||
| }; | ||
|
|
||
| // Minimal interface mirroring the API endpoints the script needs. Tests | ||
| // substitute their own implementation that goes through supertest+JWT | ||
| // instead of axios+APIKEY, so the loop logic is exercised against a real | ||
| // running server without dragging in apikey-file or axios setup. | ||
| export type CompactAllApi = { | ||
| listAllPads(): Promise<string[]>; | ||
| getRevisionsCount(padId: string): Promise<number>; | ||
| compactPad(padId: string, keepRevisions: number | null): Promise<void>; | ||
| }; | ||
|
|
||
| export type CompactAllReport = { | ||
| total: number; | ||
| ok: number; | ||
| failed: number; | ||
| totalRevsBefore: number; | ||
| totalRevsAfter: number; | ||
| }; | ||
|
|
||
| export type CompactAllLogger = { | ||
| info(msg: string): void; | ||
| error(msg: string): void; | ||
| }; | ||
|
|
||
| const defaultLogger: CompactAllLogger = { | ||
| info: (m) => console.log(m), | ||
| error: (m) => console.error(m), | ||
| }; | ||
|
|
||
| // Pure-ish core: composition + per-pad error tolerance + dry-run + tally. | ||
| // Returns a structured report so tests can assert on outcomes; the CLI | ||
| // shell maps it to an exit code. | ||
| export const runCompactAll = async ( | ||
| api: CompactAllApi, opts: CompactAllOpts, | ||
| logger: CompactAllLogger = defaultLogger, | ||
| ): Promise<CompactAllReport> => { | ||
| let padIds: string[]; | ||
| try { | ||
| padIds = await api.listAllPads(); | ||
| } catch (e: any) { | ||
| logger.error(`listAllPads failed: ${e.message ?? e}`); | ||
| return {total: 0, ok: 0, failed: 1, totalRevsBefore: 0, totalRevsAfter: 0}; | ||
| } | ||
|
|
||
| if (padIds.length === 0) { | ||
| logger.info('No pads on this instance.'); | ||
| return {total: 0, ok: 0, failed: 0, totalRevsBefore: 0, totalRevsAfter: 0}; | ||
| } | ||
|
|
||
| const strategy = opts.keepRevisions == null | ||
| ? 'collapse all history' | ||
| : `keep last ${opts.keepRevisions} revisions`; | ||
| logger.info(`Found ${padIds.length} pad(s). Strategy: ${strategy}` + | ||
| `${opts.dryRun ? ' (dry run — no writes)' : ''}.`); | ||
|
|
||
| const report: CompactAllReport = { | ||
| total: padIds.length, ok: 0, failed: 0, | ||
| totalRevsBefore: 0, totalRevsAfter: 0, | ||
| }; | ||
|
|
||
| for (let i = 0; i < padIds.length; i++) { | ||
| const padId = padIds[i]; | ||
| const idx = `[${i + 1}/${padIds.length}]`; | ||
|
|
||
| let before: number; | ||
| try { | ||
| before = await api.getRevisionsCount(padId); | ||
| } catch (e: any) { | ||
| logger.error(`${idx} ${padId}: getRevisionsCount failed: ${e.message ?? e}`); | ||
| report.failed++; | ||
| continue; | ||
| } | ||
|
|
||
| if (opts.dryRun) { | ||
| logger.info(`${idx} ${padId}: ${before + 1} revision(s) — would compact`); | ||
| report.totalRevsBefore += before + 1; | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| await api.compactPad(padId, opts.keepRevisions); | ||
| } catch (e: any) { | ||
| logger.error(`${idx} ${padId}: compactPad failed: ${e.message ?? e}`); | ||
| report.failed++; | ||
| continue; | ||
| } | ||
|
|
||
| let after: number | undefined; | ||
| try { after = await api.getRevisionsCount(padId); } | ||
| catch { /* main op already succeeded; post-count is informational */ } | ||
|
|
||
| if (after != null) { | ||
| logger.info(`${idx} ${padId}: ${before + 1} → ${after + 1} revision(s)`); | ||
| report.totalRevsBefore += before + 1; | ||
| report.totalRevsAfter += after + 1; | ||
| } else { | ||
| logger.info(`${idx} ${padId}: compacted (post-count unavailable)`); | ||
| } | ||
| report.ok++; | ||
| } | ||
|
|
||
| if (opts.dryRun) { | ||
| logger.info(''); | ||
| logger.info(`Dry run complete. ${padIds.length} pad(s), ` + | ||
| `${report.totalRevsBefore} total revision(s) — re-run ` + | ||
| 'without --dry-run to compact.'); | ||
| } else { | ||
| logger.info(''); | ||
| logger.info(`Done. ${report.ok} pad(s) compacted, ${report.failed} failed. ` + | ||
| `Revisions: ${report.totalRevsBefore} → ${report.totalRevsAfter} ` + | ||
| `(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`); | ||
| } | ||
|
|
||
| return report; | ||
| }; | ||
|
|
||
| export const parseArgs = (argv: string[]): CompactAllOpts | null => { | ||
| const opts: CompactAllOpts = {keepRevisions: null, dryRun: false}; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a === '--dry-run') { | ||
| opts.dryRun = true; | ||
| } else if (a === '--keep') { | ||
| const v = argv[++i]; | ||
| const n = Number(v); | ||
| if (!Number.isInteger(n) || n < 0) { | ||
| console.error(`--keep expects a non-negative integer; got ${v}`); | ||
| return null; | ||
| } | ||
| opts.keepRevisions = n; | ||
| } else { | ||
| return null; | ||
| } | ||
| } | ||
| return opts; | ||
| }; | ||
|
|
||
| // CLI entry point. Skipped when this file is imported (e.g. by tests), | ||
| // so the test harness can use `runCompactAll` directly without network. | ||
| const usage = () => { | ||
| console.error('Usage:'); | ||
| console.error(' node bin/compactAllPads.js'); | ||
| console.error(' node bin/compactAllPads.js --keep <N>'); | ||
| console.error(' node bin/compactAllPads.js --dry-run'); | ||
| process.exit(2); | ||
| }; | ||
|
|
||
| const isMain = require.main === module; | ||
| if (isMain) { | ||
| process.on('unhandledRejection', (err) => { throw err; }); | ||
|
|
||
| const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings(); | ||
| axios.defaults.baseURL = | ||
| `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`; | ||
|
|
||
| const opts = parseArgs(process.argv.slice(2)); | ||
| if (!opts) usage(); | ||
|
|
||
| const apikey = fs.readFileSync( | ||
| path.join(__dirname, '../APIKEY.txt'), {encoding: 'utf-8'}).trim(); | ||
|
|
||
| // Bind the abstract API to axios + APIKEY auth for the CLI shell. | ||
| const cliApi: CompactAllApi = { | ||
| async listAllPads() { | ||
| const apiInfo = await axios.get('/api/'); | ||
| const apiVersion: string | undefined = apiInfo.data.currentVersion; | ||
| if (!apiVersion) throw new Error('No version set in API'); | ||
| // Stash on this for subsequent calls. Avoids a per-call /api/ ping. | ||
| (cliApi as any)._apiVersion = apiVersion; | ||
| const r = await axios.get(`/api/${apiVersion}/listAllPads?apikey=${apikey}`); | ||
| if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); | ||
| return r.data.data.padIDs ?? []; | ||
| }, | ||
| async getRevisionsCount(padId: string) { | ||
| const v = (cliApi as any)._apiVersion; | ||
| const r = await axios.get( | ||
| `/api/${v}/getRevisionsCount?apikey=${apikey}` + | ||
| `&padID=${encodeURIComponent(padId)}`); | ||
| if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); | ||
| return r.data.data.revisions; | ||
| }, | ||
| async compactPad(padId: string, keepRevisions: number | null) { | ||
| const v = (cliApi as any)._apiVersion; | ||
| const params = new URLSearchParams({apikey, padID: padId}); | ||
| if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions)); | ||
| const r = await axios.post(`/api/${v}/compactPad?${params.toString()}`); | ||
| if (r.data.code !== 0) throw new Error(JSON.stringify(r.data)); | ||
| }, | ||
| }; | ||
|
|
||
| (async () => { | ||
| const report = await runCompactAll(cliApi, opts!); | ||
| if (report.failed > 0) process.exit(1); | ||
| })(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| 'use strict'; | ||
|
|
||
| /* | ||
| * Compact a pad's revision history to reclaim database space. | ||
| * | ||
| * Usage: | ||
| * node bin/compactPad.js <padID> # collapse all history | ||
| * node bin/compactPad.js <padID> --keep N # keep only the last N revisions | ||
| * | ||
| * Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the | ||
| * compactPad HTTP API so admins can trigger it from the CLI without | ||
| * routing through the admin settings UI. Destructive — export the pad as | ||
| * `.etherpad` first for backup. | ||
| * | ||
| * Issue #6194: long-lived pads with heavy edit history accumulate hundreds | ||
| * of megabytes in the DB; this tool is the per-pad brick for reclaiming | ||
| * that space without rotating to a new pad ID. | ||
| */ | ||
| import path from 'node:path'; | ||
| import fs from 'node:fs'; | ||
| import process from 'node:process'; | ||
| import axios from 'axios'; | ||
|
|
||
| // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an | ||
| // unhandled rejection into an uncaught exception, which does cause Node.js to exit. | ||
| process.on('unhandledRejection', (err) => { throw err; }); | ||
|
|
||
| const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings(); | ||
|
|
||
| axios.defaults.baseURL = | ||
| `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`; | ||
|
|
||
| const usage = () => { | ||
| console.error('Usage:'); | ||
| console.error(' node bin/compactPad.js <padID>'); | ||
| console.error(' node bin/compactPad.js <padID> --keep <N>'); | ||
| process.exit(2); | ||
| }; | ||
|
|
||
| const args = process.argv.slice(2); | ||
| if (args.length < 1 || args.length > 3) usage(); | ||
| const padId = args[0]; | ||
|
|
||
| let keepRevisions: number | null = null; | ||
| if (args.length === 3) { | ||
| if (args[1] !== '--keep') usage(); | ||
| keepRevisions = Number(args[2]); | ||
| if (!Number.isInteger(keepRevisions) || keepRevisions < 0) { | ||
| console.error(`--keep expects a non-negative integer; got ${args[2]}`); | ||
| process.exit(2); | ||
| } | ||
| } | ||
|
|
||
| // get the API Key | ||
| const filePath = path.join(__dirname, '../APIKEY.txt'); | ||
| const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'}).trim(); | ||
|
|
||
| (async () => { | ||
| const apiInfo = await axios.get('/api/'); | ||
| const apiVersion: string | undefined = apiInfo.data.currentVersion; | ||
| if (!apiVersion) throw new Error('No version set in API'); | ||
|
|
||
| // Pre-flight: show current revision count so operators can eyeball impact. | ||
| const countUri = `/api/${apiVersion}/getRevisionsCount?apikey=${apikey}&padID=${padId}`; | ||
| const countRes = await axios.get(countUri); | ||
| if (countRes.data.code !== 0) { | ||
| console.error(`getRevisionsCount failed: ${JSON.stringify(countRes.data)}`); | ||
| process.exit(1); | ||
| } | ||
| const before: number = countRes.data.data.revisions; | ||
| const strategy = keepRevisions == null ? 'collapse all' : `keep last ${keepRevisions}`; | ||
| console.log(`Pad ${padId}: ${before + 1} revision(s). Strategy: ${strategy}.`); | ||
|
|
||
| const params = new URLSearchParams({apikey, padID: padId}); | ||
| if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions)); | ||
| const result = await axios.post(`/api/${apiVersion}/compactPad?${params.toString()}`); | ||
| if (result.data.code !== 0) { | ||
| console.error(`compactPad failed: ${JSON.stringify(result.data)}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| // Post-flight: the pad is now compacted. Re-read the rev count so the | ||
| // operator sees concrete savings. | ||
| const afterRes = await axios.get(countUri); | ||
| const after: number | undefined = afterRes.data?.data?.revisions; | ||
| if (after != null) { | ||
| console.log(`Done. Pad ${padId}: ${after + 1} revision(s) remaining ` + | ||
| `(was ${before + 1}).`); | ||
| } else { | ||
| console.log('Done.'); | ||
| } | ||
| })(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Cli hardcodes http:// baseurl
📘 Rule violation☼ ReliabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools