querysub 0.619.0 → 0.621.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/appSecrets.ts CHANGED
@@ -2,7 +2,6 @@ import fs from "fs";
2
2
  import { lazy } from "socket-function/src/caching";
3
3
  import { ArchivesBackblaze } from "sliftutils/storage/backblaze";
4
4
  import { STORAGE_DIR, getDomain, getBackblazePath } from "./src/misc/appPaths";
5
- import { Querysub } from "./src/4-querysub/Querysub";
6
5
  import { formatDateTime } from "socket-function/src/formatting/format";
7
6
 
8
7
  const keysArchives = lazy(() => new ArchivesBackblaze({ bucketName: getDomain() }));
@@ -26,8 +25,11 @@ export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: s
26
25
  export async function getAppSecret(key: string): Promise<string | undefined> {
27
26
  // HACK: This is used by the storage server. Which we want to shut down immediately. That way the next instance can start up. The sword server already looks for notifications about when it's going to be shutting down and it flushes to disk early in this case, so it should be fairly safe to hard kill it.
28
27
  doImmediateShutdown();
28
+
29
+ // Actually maybe the wrapper should do this instead.
29
30
  // Import query sub so our logs go to our actual log server. Otherwise we won't know if we get any errors.
30
- Querysub;
31
+ //await import("./src/4-querysub/Querysub");
32
+
31
33
  if (key.startsWith("backblaze.json.")) {
32
34
  let creds = JSON.parse(fs.readFileSync(getBackblazePath(), "utf8")) as { applicationKeyId?: string; applicationKey?: string };
33
35
  if (key === "backblaze.json.applicationKeyId") return creds.applicationKeyId;
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ require("typenode");
4
+ require("../src/deployManager/emergencyUpdateMain");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.619.0",
3
+ "version": "0.621.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -25,6 +25,9 @@
25
25
  "mcp2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts",
26
26
  "mc2": "yarn typenode ./src/diagnostics/debugger/mcp-server.ts --cwd D:/repos/qs-cyoa/",
27
27
  "autofix": "yarn typenode ./src/diagnostics/logs/errorTickets/autoFixerEntry.ts",
28
+ "thin": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts",
29
+ "thin-watch": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts --watch",
30
+ "thin-dry": "yarn typenode ./src/archiveapps/snapshotThinningEntry.ts --dry-run",
28
31
  "parameters-timeline": "yarn typenode ./src/deployManager/parametersTimelineMain.ts",
29
32
  "setup-machine": "yarn typenode ./src/deployManager/setupMachineMain.ts",
30
33
  "stop-machine": "yarn typenode ./src/deployManager/stopMachineMain.ts",
@@ -52,6 +55,7 @@
52
55
  "error-watch-public": "./bin/error-watch-public.js",
53
56
  "audit-imports": "./bin/audit-imports.js",
54
57
  "audit-disk-values": "./bin/audit-disk-values.js",
58
+ "emergency-update": "./bin/emergency-update.js",
55
59
  "mcp-indexed-logs": "./bin/mcp-indexed-logs.js",
56
60
  "autofix": "./bin/autofix.js",
57
61
  "storageserve": "./bin/storageserve.js"
@@ -75,7 +79,7 @@
75
79
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
76
80
  "pako": "^2.1.0",
77
81
  "peggy": "^5.0.6",
78
- "sliftutils": "^1.7.88",
82
+ "sliftutils": "^1.7.90",
79
83
  "socket-function": "^1.2.30",
80
84
  "terser": "^5.31.0",
81
85
  "typenode": "^6.6.1",
@@ -44,13 +44,22 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
44
44
  const HETZNER = `https://65-109-93-113.querysubtest.com:5234/file/${STORAGE_ACCOUNT}/${bucket}/storage/storagerouting.json`;
45
45
  const BACKBLAZE = `https://f002.backblazeb2.com/file/${bucket}/storage/storagerouting.json`;
46
46
 
47
- function stripeSources(urls: string[]) {
47
+ function stripeSources(urls: string[], config: { routeVersion: number }) {
48
48
  let sources: Omit<Exclude<RemoteConfigBase, string>, "validWindow">[] = [];
49
49
  for (let j = 0; j < urls.length; j++) {
50
50
  // Shift by j
51
51
  let currentUrls = urls.slice(j).concat(urls.slice(0, j));
52
52
  for (let i = 0; i < urls.length; i++) {
53
- sources.push({ type: "remote", url: currentUrls[i], route: [i / urls.length, (i + 1) / urls.length], });
53
+ let url = new URL(currentUrls[i]);
54
+ let routeStart = i / urls.length;
55
+ let routeEnd = (i + 1) / urls.length;
56
+ sources.push({
57
+ type: "remote",
58
+ url: currentUrls[i],
59
+ route: [routeStart, routeEnd],
60
+ // IMPORTANT! name decides where our files are stored. We can't have two things writing to the same folder at the same time. But it also means if we reuse the name, then it has to be the same data.
61
+ name: url.hostname.replaceAll(/[^a-z0-9-]/g, "_") + "_v" + config.routeVersion + "_" + routeStart + "_" + routeEnd,
62
+ });
54
63
  }
55
64
  }
56
65
  return sources;
@@ -66,14 +75,15 @@ function archiveBuilder(bucket: string, overrides: Partial<RemoteConfigBase>) {
66
75
  sources: [
67
76
  // NOTE: Once we add new sources, we need to add something at a new start time in the future so we have time to move the files.
68
77
  // TODO: EVENTUALLY, Once we have enough data, instead of restriping, we need to bisect the data. That way we can slowly move over the data instead of trying to rewrite all of our data all at once. However, for the moment, moving all the data is probably fine. On the data center, we should be able to move terabytes of data every hour, so we should be able to handle quite a bit, especially if we have a few days to run the restriping.
69
- ...stripeSources([ONTARIO, HETZNER]),
70
- { type: "backblaze", url: BACKBLAZE, },
78
+ // IMPORTANT! Every time we re-stripe, we need to increment the route version. Otherwise, we could reuse a folder that we previously used that's no longer in sync. which will cause us to resurrect old data, which is very bad. We don't want to do that...
79
+ ...stripeSources([ONTARIO, HETZNER], { routeVersion: 1 }),
80
+ { type: "backblaze", url: BACKBLAZE, name: "backblaze" },
71
81
  ]
72
82
  },
73
83
  ]);
74
84
 
75
85
  return createArchives({
76
- version: 22,
86
+ version: 23,
77
87
  sources,
78
88
  });
79
89
  }
@@ -159,6 +169,29 @@ export function getArchives2Public(folder: string) {
159
169
  return nestBucket(folder, archivesBuilderCache(domain + "-public", { public: true, allowedOrigins: getAllowedOrigins(getDomain()) }));
160
170
  }
161
171
 
172
+ export function getArchives2Debug(folder: string) {
173
+ let routeVersion = 1;
174
+ let archives = createArchives({
175
+ version: 20,
176
+ sources: [
177
+ // IMPORTANT! If we start changing around routes, it could resurrect old data. We can fix this via incrementing the route version.
178
+ {
179
+ type: "remote", url: `https://99-250-124-91.querysubtest.com:5239/file/${STORAGE_ACCOUNT}/${folder}/storage/storagerouting.json`,
180
+ validWindow: [0, Number.MAX_SAFE_INTEGER],
181
+ route: [0, 0.5],
182
+ name: "storagea_v" + routeVersion,
183
+ },
184
+ {
185
+ type: "remote", url: `https://65-109-93-113.querysubtest.com:5240/file/${STORAGE_ACCOUNT}/${folder}/storage/storagerouting.json`,
186
+ validWindow: [0, Number.MAX_SAFE_INTEGER],
187
+ route: [0.5, 1],
188
+ name: "storageb_v" + routeVersion,
189
+ },
190
+ ],
191
+ });
192
+ return nestBucket(folder, archives);
193
+ }
194
+
162
195
 
163
196
  export async function initArchives2() {
164
197
  let buckets = [
@@ -4,7 +4,8 @@ import { decodeNodeId } from "sliftutils/misc/https/certs";
4
4
  import { getOwnNodeId, getOwnNodeIdAssert } from "../../-f-node-discovery/NodeDiscovery";
5
5
  import { pathValueArchives } from "../pathValueArchives";
6
6
  import { ArchiveLocker, ArchiveTransaction } from "./ArchiveLocks";
7
- import { ArchivesSyncStatus, copyArchiveFile, IArchives } from "sliftutils/storage/IArchives";
7
+ import { ArchivesSyncStatus, IArchives } from "sliftutils/storage/IArchives";
8
+ import { copyArchiveFile, moveArchiveFile } from "sliftutils/storage/archiveHelpers";
8
9
  import { formatNumber, formatTime } from "socket-function/src/formatting/format";
9
10
  import { blue, green, magenta, red } from "socket-function/src/formatting/logColors";
10
11
  import { devDebugbreak, getDomain } from "../../config";
@@ -35,15 +36,7 @@ const ASSUME_CONFIRMED_BEFORE = Date.parse("2026-07-23T00:00:00.000Z");
35
36
  const CONCURRENT_READ_COUNT = 128;
36
37
  const CONCURRENT_WRITE_COUNT = 128;
37
38
  const CONCURRENT_TRANSACTION_READS = 8;
38
- // When getFiles fails, how long we wait on the lock store's sync status before assuming the store is unreachable and falling back to non-authoritative (backblaze) reads.
39
- const FALLBACK_SYNC_STATUS_TIMEOUT = timeInSecond * 20;
40
39
 
41
- /** Moves by copy-then-delete (IArchives has no move). copyArchiveFile stamps the target with the source's write time, so ordering-by-writeTime survives the move. A missing source means another process already moved it, which is fine. */
42
- async function moveArchiveFile(from: IArchives, to: IArchives, path: string): Promise<void> {
43
- let copied = await copyArchiveFile({ from, to, path, noFallbacks: true });
44
- if (!copied) return;
45
- await from.del(path);
46
- }
47
40
 
48
41
  export function createArchiveLocker2(config: {
49
42
  archiveValues: IArchives;
@@ -97,7 +90,7 @@ export function createArchiveLocker2(config: {
97
90
  return;
98
91
  }
99
92
  try {
100
- await moveArchiveFile(archives, archiveRecycleBin, key);
93
+ await moveArchiveFile({ from: archives, to: archiveRecycleBin, path: key, noFallbacks: true });
101
94
  logNodeStats(`archives|Deleted TΔ`, formatNumber, 1);
102
95
  } catch {
103
96
  // It was probably just moved by another process
@@ -134,7 +127,7 @@ export function createArchiveLocker2(config: {
134
127
  if (correctFiles.has(file)) return;
135
128
  let info = await archiveValues.getInfo(file);
136
129
  console.log(`Moving file ${file}, ${moveCount}/${valuePaths.size}, ${formatNumber(info?.size || 0)} bytes`);
137
- await moveArchiveFile(archiveValues, archiveRecycleBin, file);
130
+ await moveArchiveFile({ from: archiveValues, to: archiveRecycleBin, path: file, noFallbacks: true });
138
131
  console.log(`Moved file ${file}, ${moveCount}/${valuePaths.size}`);
139
132
  }
140
133
  let moveFileParallel = runInParallel({ parallelCount: 8 }, moveFile);
@@ -155,7 +148,8 @@ export function createArchiveLocker2(config: {
155
148
  let isInRecycleBin = !!(await archiveRecycleBin.getInfo(file));
156
149
  if (isInRecycleBin) {
157
150
  console.log(`Restoring ${file}, ${restoreCount++}/${files.length}`);
158
- await moveArchiveFile(archiveRecycleBin, archiveValues, file);
151
+ // We are really careful to never delete from the recycle bin. Restores use copy instead of move, because move could theoretically have a bug where it deletes the file instead of moving it, and then our backups are gone. Copying barely costs any extra storage and isn't much slower, so move is reserved for the other direction (moving files INTO the recycle bin), where the source is still recoverable if something goes wrong.
152
+ await copyArchiveFile({ from: archiveRecycleBin, to: archiveValues, path: file, noFallbacks: true });
159
153
  } else {
160
154
  console.log(`File ${file} is not in the recycle bin, skipping, ${restoreCount++}/${files.length}`);
161
155
  }
@@ -0,0 +1,364 @@
1
+ import "../inject";
2
+
3
+ import { Querysub } from "../4-querysub/Querysub";
4
+ import { logErrors } from "../errors";
5
+ import { disablePathAuditer } from "../diagnostics/pathAuditerCallback";
6
+ import { isNodeTrue, sort, timeInDay, timeInHour, timeInWeek, timeInYear } from "socket-function/src/misc";
7
+ import { runInfinitePollCallAtStart, runInParallel } from "socket-function/src/batching";
8
+ import { green, magenta, red, yellow } from "socket-function/src/formatting/logColors";
9
+ import { formatNumber, formatTime } from "socket-function/src/formatting/format";
10
+ import { archiveJSONT } from "../-a-archives/archivesJSONT";
11
+ import { archives, archivesRecycleBin } from "../0-path-value-core/pathValueArchives";
12
+ import { lazy } from "socket-function/src/caching";
13
+ import yargs from "yargs";
14
+ import { getArchives2 } from "../-a-archives/archives2";
15
+
16
+ const timeInMonth = timeInDay * 30;
17
+
18
+ const POLL_INTERVAL = timeInHour * 24;
19
+
20
+ const MIN_PER_RANGE = 10;
21
+ const thinningRanges: {
22
+ timeRange: number;
23
+ modulus: number;
24
+ }[] = [
25
+ { timeRange: timeInDay * 3, modulus: 1 },
26
+ { timeRange: timeInWeek * 2, modulus: 2 },
27
+ { timeRange: timeInMonth * 2, modulus: 8 },
28
+ { timeRange: timeInYear * 100, modulus: 64 },
29
+ ];
30
+
31
+ // Unreferenced recycle bin files younger than this are kept. Deleting an unreferenced recycle file too eagerly would destroy the backup of anything recycled since the last kept snapshot, so orphans only die once they have sat unreferenced long enough that nothing could still want them back.
32
+ const RECYCLE_ORPHAN_MIN_AGE = timeInMonth * 2;
33
+
34
+ const INDEX_KEY = "index";
35
+ const PARALLEL_DELETE_COUNT = 32;
36
+ const PARALLEL_READ_COUNT = 32;
37
+ const PROGRESS_LOG_INTERVAL = 10 * 1000;
38
+
39
+ const snapshotsArchive = lazy(() => getArchives2("snapshots"));
40
+ const snapshotThinningArchive = lazy(() => getArchives2("snapshot-thinning-index"));
41
+
42
+ type IndexMappings = {
43
+ nextIndex: number;
44
+ indexes: { [snapshotFile: string]: number };
45
+ };
46
+
47
+ const indexMappingStore = archiveJSONT<IndexMappings>(snapshotThinningArchive);
48
+
49
+ let yargObj = isNodeTrue() && yargs(process.argv)
50
+ .option("watch", { type: "boolean", desc: "Watch, and run thinning every hour." })
51
+ .option("dry-run", { type: "boolean", desc: "Don't actually delete anything, just log what would happen." })
52
+ .argv || {};
53
+
54
+ type SnapshotInfo = {
55
+ file: string;
56
+ time: number;
57
+ size: number;
58
+ };
59
+
60
+ function parseSnapshotTime(file: string): number {
61
+ let parts = file.split(" ");
62
+ for (let part of parts) {
63
+ let [key, value] = part.split("=");
64
+ if (key === "time") return +value;
65
+ }
66
+ throw new Error(`Snapshot file name has no time field: ${file}`);
67
+ }
68
+
69
+ async function listSnapshots(): Promise<SnapshotInfo[]> {
70
+ let infos = await snapshotsArchive().findInfo("");
71
+ let result: SnapshotInfo[] = [];
72
+ for (let info of infos) {
73
+ result.push({
74
+ file: info.path,
75
+ time: parseSnapshotTime(info.path),
76
+ size: info.size,
77
+ });
78
+ }
79
+ return result;
80
+ }
81
+
82
+ async function loadIndexMappings(): Promise<IndexMappings> {
83
+ let existing = await indexMappingStore.get(INDEX_KEY);
84
+ if (existing) return existing;
85
+ return { nextIndex: 0, indexes: {} };
86
+ }
87
+
88
+ function assignIndexes(snapshots: SnapshotInfo[], mappings: IndexMappings): {
89
+ mappings: IndexMappings;
90
+ changed: boolean;
91
+ } {
92
+ let changed = false;
93
+ let nextMappings: IndexMappings = {
94
+ nextIndex: mappings.nextIndex,
95
+ indexes: { ...mappings.indexes },
96
+ };
97
+
98
+ let currentFiles = new Set(snapshots.map(s => s.file));
99
+ for (let file of Object.keys(nextMappings.indexes)) {
100
+ if (!currentFiles.has(file)) {
101
+ delete nextMappings.indexes[file];
102
+ changed = true;
103
+ }
104
+ }
105
+
106
+ let unassigned = snapshots.filter(s => !(s.file in nextMappings.indexes));
107
+ sort(unassigned, s => s.time);
108
+ for (let snap of unassigned) {
109
+ nextMappings.indexes[snap.file] = nextMappings.nextIndex++;
110
+ changed = true;
111
+ }
112
+ return { mappings: nextMappings, changed };
113
+ }
114
+
115
+ function pickThinningRange(ageMs: number) {
116
+ for (let range of thinningRanges) {
117
+ if (ageMs <= range.timeRange) return range;
118
+ }
119
+ return thinningRanges[thinningRanges.length - 1];
120
+ }
121
+
122
+ function decideThinning(snapshots: SnapshotInfo[], mappings: IndexMappings, now: number): {
123
+ keep: SnapshotInfo[];
124
+ remove: SnapshotInfo[];
125
+ } {
126
+ let bucketed = new Map<typeof thinningRanges[number], SnapshotInfo[]>();
127
+ for (let snap of snapshots) {
128
+ let range = pickThinningRange(now - snap.time);
129
+ let list = bucketed.get(range);
130
+ if (!list) {
131
+ list = [];
132
+ bucketed.set(range, list);
133
+ }
134
+ list.push(snap);
135
+ }
136
+
137
+ let keep: SnapshotInfo[] = [];
138
+ let remove: SnapshotInfo[] = [];
139
+ for (let [range, list] of bucketed) {
140
+ if (list.length <= MIN_PER_RANGE || range.modulus <= 1) {
141
+ keep.push(...list);
142
+ continue;
143
+ }
144
+ let rangeKeep: SnapshotInfo[] = [];
145
+ let rangeRemove: SnapshotInfo[] = [];
146
+ for (let snap of list) {
147
+ let idx = mappings.indexes[snap.file];
148
+ if (idx % range.modulus === 0) {
149
+ rangeKeep.push(snap);
150
+ } else {
151
+ rangeRemove.push(snap);
152
+ }
153
+ }
154
+ sort(rangeRemove, s => s.time);
155
+ while (rangeRemove.length > 0 && rangeKeep.length < MIN_PER_RANGE) {
156
+ let restored = rangeRemove.pop();
157
+ if (!restored) break;
158
+ rangeKeep.push(restored);
159
+ }
160
+ keep.push(...rangeKeep);
161
+ remove.push(...rangeRemove);
162
+ }
163
+ return { keep, remove };
164
+ }
165
+
166
+ async function readSnapshotFiles(file: string): Promise<string[]> {
167
+ let buf = await snapshotsArchive().get(file);
168
+ if (!buf) {
169
+ throw new Error(`Snapshot file unreadable: ${file}`);
170
+ }
171
+ let text = buf.toString();
172
+ if (!text) return [];
173
+ return text.split("\n");
174
+ }
175
+
176
+ async function runThinningIteration(config: { dryRun: boolean }) {
177
+ let { dryRun } = config;
178
+ let prefix = dryRun ? yellow(`[DRY RUN] `) : ``;
179
+ let now = Date.now();
180
+
181
+ console.log(magenta(`${prefix}Starting snapshot thinning iteration (dryRun=${dryRun})`));
182
+
183
+ let snapshots = await listSnapshots();
184
+ sort(snapshots, s => s.time);
185
+
186
+ let mappingsBase = await loadIndexMappings();
187
+ let { mappings, changed } = assignIndexes(snapshots, mappingsBase);
188
+
189
+ if (changed && !dryRun) {
190
+ await indexMappingStore.set(INDEX_KEY, mappings);
191
+ } else if (changed) {
192
+ console.log(`${prefix}Would update index mappings (next index ${mappings.nextIndex}, ${Object.keys(mappings.indexes).length} entries)`);
193
+ }
194
+
195
+ let totalSnapshotBytes = snapshots.reduce((s, x) => s + x.size, 0);
196
+ console.log(`${prefix}Snapshots: ${formatNumber(snapshots.length)}, total snapshot file bytes: ${formatNumber(totalSnapshotBytes)}`);
197
+
198
+ let { keep, remove } = decideThinning(snapshots, mappings, now);
199
+ let removedBytes = remove.reduce((s, x) => s + x.size, 0);
200
+ console.log(`${prefix}Snapshots to keep: ${formatNumber(keep.length)}, to remove: ${formatNumber(remove.length)} (${formatNumber(removedBytes)} bytes)`);
201
+
202
+ for (let range of thinningRanges) {
203
+ let inRangeAll = snapshots.filter(s => pickThinningRange(now - s.time) === range);
204
+ let inRangeKeep = keep.filter(s => pickThinningRange(now - s.time) === range);
205
+ console.log(` Range age<=${formatTime(range.timeRange)} modulus=${range.modulus}: ${formatNumber(inRangeAll.length)} => ${formatNumber(inRangeKeep.length)}`);
206
+ }
207
+
208
+ let recycleListStart = Date.now();
209
+ let recycleInfos = await archivesRecycleBin().findInfo("");
210
+ let recycleListDuration = Date.now() - recycleListStart;
211
+ let recycleSet = new Map<string, { size: number; createTime: number }>();
212
+ for (let info of recycleInfos) {
213
+ recycleSet.set(info.path, { size: info.size, createTime: info.createTime });
214
+ }
215
+ let totalRecycleBytes = 0;
216
+ for (let info of recycleSet.values()) totalRecycleBytes += info.size;
217
+ console.log(`${prefix}Recycle bin files: ${formatNumber(recycleSet.size)}, total bytes: ${formatNumber(totalRecycleBytes)}`);
218
+
219
+ let parallelRead = runInParallel({ parallelCount: PARALLEL_READ_COUNT }, readSnapshotFiles);
220
+ let referencedFiles = new Set<string>();
221
+ let keepFileLists = await Promise.all(keep.map(s => parallelRead(s.file)));
222
+ for (let list of keepFileLists) {
223
+ for (let f of list) referencedFiles.add(f);
224
+ }
225
+
226
+ let liveInfos = await archives().findInfo("");
227
+ let totalLiveBytes = 0;
228
+ for (let info of liveInfos) totalLiveBytes += info.size;
229
+ console.log(`${prefix}Production archives (live path-values): ${formatNumber(liveInfos.length)} files, ${formatNumber(totalLiveBytes)} bytes`);
230
+
231
+ for (let info of liveInfos) {
232
+ referencedFiles.add(info.path);
233
+ }
234
+
235
+ let recycleToDelete: { file: string; size: number }[] = [];
236
+ let youngOrphanCount = 0;
237
+ let youngOrphanBytes = 0;
238
+ for (let [file, info] of recycleSet) {
239
+ if (referencedFiles.has(file)) continue;
240
+ if (now - info.createTime < RECYCLE_ORPHAN_MIN_AGE) {
241
+ youngOrphanCount++;
242
+ youngOrphanBytes += info.size;
243
+ continue;
244
+ }
245
+ recycleToDelete.push({ file, size: info.size });
246
+ }
247
+ let recycleDeleteBytes = recycleToDelete.reduce((s, x) => s + x.size, 0);
248
+ console.log(`${prefix}Planned: delete ${formatNumber(remove.length)} snapshots (${formatNumber(removedBytes)} bytes), ${formatNumber(recycleToDelete.length)} recycle bin files (${formatNumber(recycleDeleteBytes)} bytes). Keeping ${formatNumber(youngOrphanCount)} unreferenced recycle bin files (${formatNumber(youngOrphanBytes)} bytes) that are younger than ${formatTime(RECYCLE_ORPHAN_MIN_AGE)}`);
249
+
250
+ function logSummary() {
251
+ let snapshotCountAfter = keep.length;
252
+ let snapshotBytesAfter = totalSnapshotBytes - removedBytes;
253
+ let recycleCountAfter = recycleSet.size - recycleToDelete.length;
254
+ let recycleBytesAfter = totalRecycleBytes - recycleDeleteBytes;
255
+ let beforeTotalBytes = totalSnapshotBytes + totalRecycleBytes;
256
+ let afterTotalBytes = snapshotBytesAfter + recycleBytesAfter;
257
+ let beforeTotalFiles = snapshots.length + recycleSet.size;
258
+ let afterTotalFiles = snapshotCountAfter + recycleCountAfter;
259
+
260
+ function pct(after: number, before: number) {
261
+ if (!before) return `100%`;
262
+ return `${(after / before * 100).toFixed(1)}%`;
263
+ }
264
+
265
+ console.log(magenta(`${prefix}===== Summary =====`));
266
+ console.log(`${prefix}Snapshots: ${formatNumber(snapshots.length)} => ${formatNumber(snapshotCountAfter)} files (${pct(snapshotCountAfter, snapshots.length)} kept), ${formatNumber(totalSnapshotBytes)} => ${formatNumber(snapshotBytesAfter)} bytes (${pct(snapshotBytesAfter, totalSnapshotBytes)} kept)`);
267
+ console.log(`${prefix}Recycle bin: ${formatNumber(recycleSet.size)} => ${formatNumber(recycleCountAfter)} files (${pct(recycleCountAfter, recycleSet.size)} kept), ${formatNumber(totalRecycleBytes)} => ${formatNumber(recycleBytesAfter)} bytes (${pct(recycleBytesAfter, totalRecycleBytes)} kept)`);
268
+ console.log(`${prefix}Total: ${formatNumber(beforeTotalFiles)} => ${formatNumber(afterTotalFiles)} files (${pct(afterTotalFiles, beforeTotalFiles)} kept), ${formatNumber(beforeTotalBytes)} => ${formatNumber(afterTotalBytes)} bytes (${pct(afterTotalBytes, beforeTotalBytes)} kept)`);
269
+ console.log(`${prefix}Live archives (unchanged): ${formatNumber(liveInfos.length)} files, ${formatNumber(totalLiveBytes)} bytes`);
270
+ console.log(`${prefix}Recycle bin findInfo("") took: ${formatTime(recycleListDuration)} (for ${formatNumber(recycleSet.size)} files)`);
271
+ }
272
+
273
+ if (dryRun) {
274
+ logSummary();
275
+ console.log(green(`${prefix}Dry run complete. No changes made.`));
276
+ return;
277
+ }
278
+
279
+ async function deleteWithProgress(label: string, files: string[], deleter: (file: string) => Promise<void>) {
280
+ if (files.length === 0) {
281
+ console.log(`No ${label} to delete`);
282
+ return;
283
+ }
284
+ let done = 0;
285
+ let failed = 0;
286
+ let startTime = Date.now();
287
+ let lastLog = startTime;
288
+ let lastLogDone = 0;
289
+ let peakRate = 0;
290
+ function logProgress() {
291
+ let nowTs = Date.now();
292
+ let elapsed = nowTs - startTime;
293
+ let avgRate = done / (elapsed / 1000);
294
+ let intervalMs = nowTs - lastLog;
295
+ let intervalRate = intervalMs > 0 ? (done - lastLogDone) / (intervalMs / 1000) : 0;
296
+ if (intervalRate > peakRate) peakRate = intervalRate;
297
+ let remaining = files.length - done;
298
+ let etaMs = avgRate > 0 ? remaining / avgRate * 1000 : 0;
299
+ console.log(` ${label}: ${formatNumber(done)}/${formatNumber(files.length)} deleted (${(done / files.length * 100).toFixed(1)}%), ${formatNumber(failed)} failed, elapsed ${formatTime(elapsed)}, ETA ${formatTime(etaMs)}, avg ${avgRate.toFixed(1)}/s, peak ${peakRate.toFixed(1)}/s`);
300
+ lastLog = nowTs;
301
+ lastLogDone = done;
302
+ }
303
+ let parallel = runInParallel({ parallelCount: PARALLEL_DELETE_COUNT }, async (file: string) => {
304
+ try {
305
+ await deleter(file);
306
+ } catch (e) {
307
+ failed++;
308
+ console.error(`Failed to delete ${file}: ${String((e as Error)?.stack || e)}`);
309
+ }
310
+ done++;
311
+ if (Date.now() - lastLog >= PROGRESS_LOG_INTERVAL) {
312
+ logProgress();
313
+ }
314
+ });
315
+ console.log(`Deleting ${formatNumber(files.length)} ${label}...`);
316
+ await Promise.all(files.map(f => parallel(f)));
317
+ logProgress();
318
+ console.log(green(`Deleted ${formatNumber(done - failed)} ${label} (${formatNumber(failed)} failed) in ${formatTime(Date.now() - startTime)}`));
319
+ }
320
+
321
+ await deleteWithProgress("snapshots", remove.map(s => s.file), async file => {
322
+ await snapshotsArchive().del(file);
323
+ });
324
+
325
+ if (remove.length > 0) {
326
+ let mappingsAfter: IndexMappings = {
327
+ nextIndex: mappings.nextIndex,
328
+ indexes: { ...mappings.indexes },
329
+ };
330
+ for (let snap of remove) {
331
+ delete mappingsAfter.indexes[snap.file];
332
+ }
333
+ await indexMappingStore.set(INDEX_KEY, mappingsAfter);
334
+ }
335
+
336
+ await deleteWithProgress("recycle bin files", recycleToDelete.map(x => x.file), async file => {
337
+ await archivesRecycleBin().del(file);
338
+ });
339
+
340
+ logSummary();
341
+ console.log(green(`Snapshot thinning complete.`));
342
+ }
343
+
344
+ async function main() {
345
+ disablePathAuditer();
346
+ await Querysub.hostService("snapshot-thinning");
347
+
348
+ let dryRun = !!yargObj["dry-run"];
349
+
350
+ if (yargObj.watch) {
351
+ console.log("Running in watch mode.");
352
+ await runInfinitePollCallAtStart(POLL_INTERVAL, () => runThinningIteration({ dryRun }));
353
+ } else {
354
+ try {
355
+ await runThinningIteration({ dryRun });
356
+ } catch (error) {
357
+ console.error((error as Error).stack ?? error);
358
+ } finally {
359
+ process.exit();
360
+ }
361
+ }
362
+ }
363
+
364
+ main().catch(logErrors);
@@ -45,7 +45,7 @@ const JSON_INDENT = 4;
45
45
  /** String sources are the shorthand for an always-valid, unsharded backblaze bucket. */
46
46
  function normalizeSource(source: RemoteConfigBase): Source {
47
47
  if (typeof source !== "string") return source;
48
- return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW };
48
+ return { type: "backblaze", url: source, validWindow: FULL_VALID_WINDOW, name: "from url" };
49
49
  }
50
50
 
51
51
  /** Where a source lives, with the bucket the config belongs to masked out so buckets that differ only by their own name group together. */
@@ -22,7 +22,6 @@ export function getSourceTags(source: HostedConfig | BackblazeConfig): SourceTag
22
22
  if (source.intermediate) tags.push({ icon: "⏳", text: "intermediate" });
23
23
  if (source.type === "remote") {
24
24
  if (source.fast) tags.push({ icon: "⚡", text: "fast" });
25
- if (source.rawDisk) tags.push({ icon: "💽", text: "rawDisk" });
26
25
  if (source.writeDelay !== undefined) tags.push({ icon: "⏱", text: `writeDelay ${formatTime(source.writeDelay)}` });
27
26
  }
28
27
  if (source.readerDiskLimit !== undefined) tags.push({ icon: "📦", text: `readCache ${formatNumber(source.readerDiskLimit)}B` });
@@ -0,0 +1,78 @@
1
+ import "../inject";
2
+
3
+ import { Querysub } from "../4-querysub/Querysub";
4
+ import { logErrors } from "../errors";
5
+ import { disablePathAuditer } from "../diagnostics/pathAuditerCallback";
6
+ import { formatDateTime } from "socket-function/src/formatting/format";
7
+ import { green, magenta } from "socket-function/src/formatting/logColors";
8
+ import { delay } from "socket-function/src/batching";
9
+ import { MachineServiceControllerBase, ServiceConfig } from "./machineSchema";
10
+
11
+ // setServiceConfigs notifies the machines without awaiting the notifications, so we linger before exiting to let them go out. If they are lost anyway (ex, the machines are unreachable), the machines still pick up the new configs on their fallback resync poll.
12
+ const NOTIFY_GRACE_PERIOD = 10 * 1000;
13
+
14
+ // The emergency workflow from the services list page, as one command: commit/publish/push querysub, commit/push the service repo, then point every service config at the new commit with no overlap. Everything here writes to the service configs archives directly, which falls back to backblaze, so this works while the storage servers (and therefore the deploy pages) are down.
15
+ async function main() {
16
+ disablePathAuditer();
17
+ await Querysub.hostService("emergency-update");
18
+
19
+ let controller = new MachineServiceControllerBase();
20
+ let commitMessage = `emergency update ${formatDateTime(Date.now())}`;
21
+
22
+ let gitInfo = await controller.getGitInfo();
23
+ console.log(magenta(`Starting emergency update of ${gitInfo.repoUrl}`), {
24
+ commitMessage,
25
+ querysubUncommittedFiles: gitInfo.querysubUncommitted.length,
26
+ uncommittedFiles: gitInfo.uncommitted.length,
27
+ });
28
+
29
+ if (gitInfo.querysubUncommitted.length > 0) {
30
+ console.log(magenta(`Committing, publishing, and pushing querysub (${gitInfo.querysubUncommitted.length} files changed)`));
31
+ await controller.commitPushAndPublishQuerysub(commitMessage);
32
+ } else {
33
+ console.log(`Querysub has no uncommitted changes, skipping the querysub publish`);
34
+ }
35
+
36
+ // Re-read, as publishing querysub updates our package.json, which then needs to be committed here
37
+ gitInfo = await controller.getGitInfo();
38
+ if (gitInfo.uncommitted.length > 0) {
39
+ console.log(magenta(`Committing and pushing the service repo (${gitInfo.uncommitted.length} files changed)`));
40
+ await controller.commitPushService(commitMessage);
41
+ } else {
42
+ console.log(`The service repo has no uncommitted changes, skipping the commit`);
43
+ }
44
+
45
+ // Re-read, as latestRef must include the commit we just pushed
46
+ gitInfo = await controller.getGitInfo();
47
+
48
+ let serviceIds = await controller.getServiceList();
49
+ let configs: ServiceConfig[] = [];
50
+ for (let serviceId of serviceIds) {
51
+ let config = await controller.getServiceConfig(serviceId);
52
+ if (config) configs.push(config);
53
+ }
54
+ let outdated = configs.filter(config =>
55
+ config.parameters.repoUrl === gitInfo.repoUrl
56
+ && config.parameters.gitRef !== gitInfo.latestRef
57
+ );
58
+ console.log(`Services: ${configs.length} total, ${outdated.length} on an old commit (latest is ${gitInfo.latestRef})`);
59
+ if (outdated.length === 0) {
60
+ console.log(green(`All services are already on the latest commit, nothing to deploy`));
61
+ return;
62
+ }
63
+
64
+ let now = Date.now();
65
+ for (let config of outdated) {
66
+ console.log(`Updating ${config.parameters.key}: ${config.parameters.gitRef} => ${gitInfo.latestRef} (release at ${formatDateTime(now)}, no overlap)`);
67
+ config.parameters.gitRef = gitInfo.latestRef;
68
+ config.parameters.releaseTime = now;
69
+ config.parameters.overlapTime = 0;
70
+ }
71
+ await controller.setServiceConfigs(outdated);
72
+ console.log(`Wrote ${outdated.length} service configs, waiting for machine notifications to go out`);
73
+ await delay(NOTIFY_GRACE_PERIOD);
74
+
75
+ console.log(green(`Emergency update complete: ${outdated.length} services updating to ${gitInfo.latestRef}`));
76
+ }
77
+
78
+ main().catch(logErrors).finally(() => process.exit());
@@ -124,6 +124,20 @@ export class SnapshotViewer extends qreact.Component {
124
124
  <div>Byte Count: {formatNumber(entry.byteCount)}</div>
125
125
  </div>
126
126
  }
127
+ {snapshotInfo &&
128
+ <div class={css.vbox(4)}>
129
+ <div class={css.boldStyle}>By status</div>
130
+ <Table
131
+ rows={summarizeSnapshotByStatus(snapshotInfo.files)}
132
+ columns={{
133
+ status: { title: "Status" },
134
+ files: { title: "Files" },
135
+ values: { title: "Values" },
136
+ bytes: { title: "Bytes" },
137
+ }}
138
+ />
139
+ </div>
140
+ }
127
141
  <Button onClick={async () => {
128
142
  let didConfirm = await seriousConfirm({
129
143
  message: `Restoring will put the servers in a bad state. You're gonna need to restart all services after this.`,
@@ -168,6 +182,26 @@ export class SnapshotViewer extends qreact.Component {
168
182
  }
169
183
  }
170
184
 
185
+ // Canonical status order, so the per-status summary rows stay in a stable, readable order.
186
+ const SNAPSHOT_STATUS_ORDER = ["live", "zombie", "recycled", "missing"];
187
+ type SnapshotStatusRow = { status: string; files: number; values: number; bytes: number };
188
+ function summarizeSnapshotByStatus(files: ArchiveSnapshotRead["files"]): SnapshotStatusRow[] {
189
+ let byStatus = new Map<string, SnapshotStatusRow>();
190
+ for (let file of files) {
191
+ let row = byStatus.get(file.state);
192
+ if (!row) {
193
+ row = { status: file.state, files: 0, values: 0, bytes: 0 };
194
+ byStatus.set(file.state, row);
195
+ }
196
+ row.files++;
197
+ row.values += file.valueCount;
198
+ row.bytes += file.byteCount;
199
+ }
200
+ let rows = Array.from(byStatus.values());
201
+ sort(rows, row => SNAPSHOT_STATUS_ORDER.indexOf(row.status));
202
+ return rows;
203
+ }
204
+
171
205
  async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
172
206
  if (component.state.downloading[file]) return;
173
207
  Querysub.localCommit(() => {
@@ -13,6 +13,7 @@ export const STORAGE_DIR = "./database-storage/";
13
13
 
14
14
  let yargObj = parseArgsFactory()
15
15
  .option("domain", { type: "string", desc: `Sets the domain` })
16
+ .option("port", { type: "number", desc: `Sets the port` })
16
17
  .argv
17
18
  ;
18
19
 
@@ -42,6 +43,10 @@ export function getDomain() {
42
43
  return yargObj.domain || querysubConfig().domain || "querysub.com";
43
44
  }
44
45
 
46
+ export function getPort(): number | undefined {
47
+ return yargObj.port;
48
+ }
49
+
45
50
  export function getBackblazePath() {
46
51
  let testPaths = [
47
52
  STORAGE_DIR + "backblaze.json",
@@ -1,23 +1,15 @@
1
1
  process.env.PRODUCTION = "true";
2
- import "./inject";
3
- import { getDomain, getPort } from "./config";
4
- import { getTrustedMachineIds } from "./-d-trust/NetworkTrust2";
5
- import { getExternalIP } from "socket-function/src/networking";
2
+ // TODO: Add this back
3
+ //import "./inject";
6
4
 
7
5
  // The storage server entry point (see bin/storageserve.js): sets up the CLI args the sliftutils storage server expects, synchronizes our network trust list into its trust store, and runs its CLI.
8
6
 
9
7
  async function main() {
10
- if (!process.argv.includes("--url")) {
11
- let ip = await getExternalIP();
12
- let ipDomain = ip.replaceAll(".", "-");
13
- process.argv.push("--url", `https://${ipDomain}.${getDomain()}:${getPort()}`);
14
- }
15
-
16
- let trustedMachineIds = await getTrustedMachineIds();
17
-
18
- let { setTrustedMachines } = await import("sliftutils/storage/remoteStorage/serverConfig");
19
- await setTrustedMachines({ account: "root", machineIds: trustedMachineIds });
20
- console.log(`Synchronized ${trustedMachineIds.length} trusted machines`);
8
+ // TODO: Add this back
9
+ // let trustedMachineIds = await getTrustedMachineIds();
10
+ // let { setTrustedMachines } = await import("sliftutils/storage/remoteStorage/serverConfig");
11
+ // await setTrustedMachines({ account: "root", machineIds: trustedMachineIds });
12
+ // console.log(`Synchronized ${trustedMachineIds.length} trusted machines`);
21
13
 
22
14
  await import("sliftutils/storage/remoteStorage/storageServerCli");
23
15
  }