querysub 0.617.0 → 0.620.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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.617.0",
3
+ "version": "0.620.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",
@@ -75,7 +78,7 @@
75
78
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
76
79
  "pako": "^2.1.0",
77
80
  "peggy": "^5.0.6",
78
- "sliftutils": "^1.7.88",
81
+ "sliftutils": "^1.7.89",
79
82
  "socket-function": "^1.2.30",
80
83
  "terser": "^5.31.0",
81
84
  "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,8 +75,9 @@ 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
  ]);
@@ -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 = [
@@ -541,9 +541,9 @@ async function runServerSyncLoops() {
541
541
  let suicideThreshold = Date.now() - SUICIDE_HEARTBEAT_THRESHOLD;
542
542
  if (!lastTime || lastTime < suicideThreshold) {
543
543
  if (!lastTime) {
544
- console.error(red(`Self node was removed due to not heartbeating. Terminating self process, as it likely has very stale data.`));
544
+ console.error(red(`Self node was removed due to heartbeat file not existing (${selfNodeId}). Terminating self process, as it likely has very stale data.`));
545
545
  } else {
546
- console.error(red(`Self node was has very old heartbeat. Terminating self process, as it likely has very stale data.`));
546
+ console.error(red(`Self node was has very old heartbeat. Terminating self process, as it likely has very stale data. ${formatDateTime(lastTime)} < ${formatDateTime(suicideThreshold)}, now is ${formatDateTime(Date.now())}`));
547
547
  }
548
548
  process.exit();
549
549
  }
@@ -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
  }
@@ -215,9 +215,4 @@ export async function loadSnapshot(config: {
215
215
  for (let i = 0; i < 10; i++) {
216
216
  console.log(green(`!!!Finished loading snapshot!!!`));
217
217
  }
218
-
219
- if (!config.noExit && !isPublic()) {
220
- // Exit so we know when this is done. Really, all the servers need to be restarted after we load the snapshot, anyway.
221
- process.exit();
222
- }
223
218
  }
@@ -35,7 +35,9 @@ const SHUTDOWN_REHOME_WINDOW = timeInMinute * 3;
35
35
  const SHUTDOWN_REHOME_POLL_INTERVAL = timeInSecond * 30;
36
36
 
37
37
  // Rehome the watches on an authority when an equivalent authority has this factor lower latency. At 2 the router's full-confidence cutoff factor (also 2) then reliably routes everything to the better node on the rewatch.
38
- const LATENCY_REHOME_FACTOR = 2;
38
+ const LATENCY_REHOME_FACTOR = 1.5;
39
+ // Jitter floor added to the candidate's latency before the factor comparison. Below roughly this many milliseconds the difference between two nodes is noise, not speed (1ms vs 2ms isn't "twice as fast"), and without the floor a candidate that momentarily reads near-zero always looks infinitely faster, so we rehome on pure jitter. Added only to the candidate, keeping us biased against needless moves.
40
+ const LATENCY_REHOME_JITTER_MS = 50;
39
41
  // Both latencies must be medians over this many samples — with fewer we don't trust either number enough to move watches over it.
40
42
  const LATENCY_REHOME_HISTORY_COUNT = 10;
41
43
  const LATENCY_REHOME_POLL_INTERVAL = timeInMinute;
@@ -186,7 +188,7 @@ export class RemoteWatcher {
186
188
  if (!areAuthoritySpecsEquivalent(entry.authoritySpec, candidate.authoritySpec)) continue;
187
189
  let candidateLatency = getNodeLatencyMedian({ nodeId: candidate.nodeId, historyCount: LATENCY_REHOME_HISTORY_COUNT });
188
190
  if (!candidateLatency || candidateLatency.historyUsed < LATENCY_REHOME_HISTORY_COUNT) continue;
189
- if (candidateLatency.latency * LATENCY_REHOME_FACTOR > current.latency) continue;
191
+ if ((candidateLatency.latency + LATENCY_REHOME_JITTER_MS) * LATENCY_REHOME_FACTOR > current.latency) continue;
190
192
  console.log(yellow(`Authority ${authorityId} has high latency (${formatTime(current.latency)}), and the equivalent authority ${candidate.nodeId} is much faster (${formatTime(candidateLatency.latency)}), so we are rehoming all paths watched on it`));
191
193
  logErrors(this.refreshAllWatches(authorityId));
192
194
  break;
@@ -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);
@@ -10,6 +10,7 @@ import { MachineDetailPage } from "./components/MachineDetailPage";
10
10
  import { Anchor } from "../library-components/ATag";
11
11
  import { DeployPage } from "./components/DeployPage";
12
12
  import { StoragePage } from "./components/storage/StoragePage";
13
+ import { RoutingTablePage } from "../diagnostics/misc-pages/RoutingTablePage";
13
14
 
14
15
  export class MachinesPage extends qreact.Component {
15
16
  private renderTabs() {
@@ -22,6 +23,7 @@ export class MachinesPage extends qreact.Component {
22
23
  { key: "services", label: "Services", otherKeys: ["service-detail"] },
23
24
  { key: "deploy", label: "Deploy", otherKeys: [] },
24
25
  { key: "storage", label: "Storage", otherKeys: [] },
26
+ { key: "routing", label: "PathValues / Functions Routing", otherKeys: [] },
25
27
  ].map(tab => {
26
28
  let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
27
29
  return <Anchor noStyles key={tab.key}
@@ -50,6 +52,7 @@ export class MachinesPage extends qreact.Component {
50
52
  {currentViewParam.value === "machine-detail" && <MachineDetailPage />}
51
53
  {currentViewParam.value === "deploy" && <DeployPage />}
52
54
  {currentViewParam.value === "storage" && <StoragePage />}
55
+ {currentViewParam.value === "routing" && <RoutingTablePage />}
53
56
  </div>
54
57
  </div>;
55
58
  }
@@ -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` });
@@ -1,6 +1,6 @@
1
1
  import { URLParam } from "../library-components/URLParam";
2
2
 
3
- export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage";
3
+ export type ViewType = "services" | "machines" | "service-detail" | "machine-detail" | "deploy" | "storage" | "routing";
4
4
 
5
5
  export const currentViewParam = new URLParam<ViewType>("machineview", "machines");
6
6
  export const selectedServiceIdParam = new URLParam("serviceId", "");
@@ -1,3 +1,6 @@
1
+ require("debugbreak")(2);
2
+ debugger;
3
+
1
4
  import { isNode } from "typesafecss";
2
5
  import { partialCopyObject } from "../../misc";
3
6
  import { canHaveChildren } from "socket-function/src/types";
@@ -80,12 +80,6 @@ export async function registerManagementPages2(config: {
80
80
  let inputPages: typeof config.pages = [];
81
81
 
82
82
 
83
- inputPages.push({
84
- title: "Routing Table",
85
- componentName: "RoutingTablePage",
86
- controllerName: "RoutingTablePageController",
87
- getModule: () => import("./misc-pages/RoutingTablePage"),
88
- });
89
83
  inputPages.push({
90
84
  title: "LOG VIEWER",
91
85
  componentName: "LogViewer3",
@@ -404,6 +398,10 @@ class ManagementRoot extends qreact.Component {
404
398
  managementPageURL.getOverride("MachinesPage"),
405
399
  currentViewParam.getOverride("storage"),
406
400
  ]}>Storage</ATag>
401
+ <ATag values={[
402
+ managementPageURL.getOverride("MachinesPage"),
403
+ currentViewParam.getOverride("routing"),
404
+ ]}>PathValues / Functions Routing</ATag>
407
405
  {pages.map(page =>
408
406
  <ATag values={[{ param: managementPageURL, value: page.componentName }]}>{page.title}</ATag>
409
407
  )}
@@ -45,19 +45,18 @@ class PathSummaryTable extends qreact.Component<{ nodeId: string; direction: Pat
45
45
 
46
46
  // One row per path-value server, grouped by machine. Reads/writes lead the line; expanding reveals that server's
47
47
  // per-path breakdown for both directions.
48
- class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; firstOfMachine: boolean }> {
48
+ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo }> {
49
49
  state = {
50
50
  expanded: false,
51
51
  };
52
52
  render() {
53
53
  let info = this.props.info;
54
- let spec = info.spec!;
54
+ let spec = info.spec;
55
55
  let expanded = this.state.expanded;
56
56
  let totals = RoutingTableSynced(getBrowserUrlNode()).getNodePathTotals(info.nodeId);
57
57
  let machineId = machineIdOf(info.nodeId);
58
58
  return <div
59
- className={css.button.vbox(6).pad2(10).fillWidth.hsl(0, 0, 99)
60
- .borderTop(this.props.firstOfMachine ? "2px solid hsl(0, 0%, 78%)" : "1px solid hsl(0, 0%, 90%)")}
59
+ className={css.button.vbox(6).pad2(10).fillWidth.hsl(0, 0, 99).borderTop("1px solid hsl(0, 0%, 88%)")}
61
60
  onClick={() => this.state.expanded = !expanded}
62
61
  >
63
62
  <div className={css.hbox(10).fillWidth}>
@@ -66,12 +65,12 @@ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; first
66
65
  ↓{totals ? formatNumber(totals.received) : "…"} ↑{totals ? formatNumber(totals.sent) : "…"}
67
66
  </span>
68
67
  <span className={css.width(`${MACHINE_CELL_WIDTH_CH}ch`).flexShrink0.colorhsl(0, 0, 45)} title={machineId}>
69
- {this.props.firstOfMachine ? machineId.slice(0, ID_CHARS) : ""}
68
+ {machineId.slice(0, ID_CHARS)}
70
69
  </span>
71
70
  <span className={css.boldStyle} title={info.nodeId}>{threadLabel(info.nodeId)}</span>
72
- <span className={css.colorhsl(0, 0, 45)}>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>
73
- <AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />
74
- {spec.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
71
+ {spec && <span className={css.colorhsl(0, 0, 45)}>{spec.routeStart.toFixed(4)} - {spec.routeEnd.toFixed(4)}</span>}
72
+ {spec && <AuthorityRangeBar start={spec.routeStart} end={spec.routeEnd} />}
73
+ {spec?.excludeDefault && <span className={css.colorhsl(0, 70, 35)}>(excludes default)</span>}
75
74
  </div>
76
75
  {expanded &&
77
76
  <div className={css.hbox(16).wrap.fillWidth.alignItems("flex-start")} onClick={e => e.stopPropagation()}>
@@ -89,42 +88,67 @@ class AuthorityNodeRow extends qreact.Component<{ info: NodeAuthorityInfo; first
89
88
  }
90
89
  }
91
90
 
92
- // Invalidate: drop every cached synced call for this node so the whole page's data disappears and refetches. Clear:
93
- // wipe the in-memory path-value stats (totals and trees) on every node, then invalidate so the now-empty stats reload.
91
+ // Invalidate: drop every cached synced call for this node so the whole page's data disappears and refetches.
94
92
  function invalidateRoutingTable(): void {
95
- RoutingTableSynced(getBrowserUrlNode()).resetAll();
93
+ let synced = RoutingTableSynced(getBrowserUrlNode());
94
+ synced.getNodePathTotals.resetAll();
95
+ synced.getNodePathSummary.resetAll();
96
96
  }
97
+ // Clear: wipe the in-memory path-value stats (totals and trees) on every node, then drop only the stats caches so the
98
+ // now-empty stats reload — leaving the specs/latency/traffic caches untouched.
97
99
  async function clearAllPathStats(nodeIds: string[]): Promise<void> {
98
100
  let synced = RoutingTableSynced(getBrowserUrlNode());
99
101
  await Promise.all(nodeIds.map(nodeId => synced.clearNodePathStats.promise(nodeId).catch(() => { })));
100
- synced.resetAll();
102
+ synced.getNodePathTotals.resetAll();
103
+ synced.getNodePathSummary.resetAll();
104
+ }
105
+
106
+ // A list of server rows, most-active first (by total path values sent + received).
107
+ class PathServerList extends qreact.Component<{ infos: NodeAuthorityInfo[]; emptyText: string }> {
108
+ render() {
109
+ let synced = RoutingTableSynced(getBrowserUrlNode());
110
+ let sumOf = (nodeId: string) => {
111
+ let totals = synced.getNodePathTotals(nodeId);
112
+ return (totals?.sent ?? 0) + (totals?.received ?? 0);
113
+ };
114
+ let infos = this.props.infos.slice();
115
+ // Two stable passes: machine/thread as a tiebreak, then sent+received descending as the primary order (so rows
116
+ // with equal — or still-loading, hence 0 — totals keep a stable machine/thread order instead of jittering).
117
+ let sep = String.fromCharCode(1);
118
+ sort(infos, x => `${machineIdOf(x.nodeId)}${sep}${threadLabel(x.nodeId)}`);
119
+ sort(infos, x => -sumOf(x.nodeId));
120
+ if (!infos.length) return <div className={css.colorhsl(0, 0, 50)}>{this.props.emptyText}</div>;
121
+ return <div className={css.vbox(0).fillWidth.bord2(0, 0, 88)}>
122
+ {infos.map(info => <AuthorityNodeRow key={info.nodeId} info={info} />)}
123
+ </div>;
124
+ }
101
125
  }
102
126
 
103
127
  export class PathValueServersSection extends qreact.Component<{ infos: NodeAuthorityInfo[] }> {
104
128
  render() {
105
129
  let infos = this.props.infos;
106
130
  let allNodeIds = infos.map(x => x.nodeId);
107
- let routableInfos = infos.filter(x => x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0);
108
- // Group each server's threads under their machine (machine, then thread), so a machine's servers sit together.
109
- let sep = String.fromCharCode(1);
110
- sort(routableInfos, x => `${machineIdOf(x.nodeId)}${sep}${threadLabel(x.nodeId)}`);
131
+ // Path value servers are the authorities (they own a route range). Every other node can still send/receive
132
+ // path values, so it gets its own list below.
133
+ let serverInfos = infos.filter(x => x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0);
134
+ let otherInfos = infos.filter(x => !(x.spec && x.spec.routeStart >= 0 && x.spec.routeEnd >= 0));
111
135
 
112
136
  let buttonClass = css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 100).whiteSpace("nowrap");
113
137
  return <div className={css.vbox(8).fillWidth}>
114
138
  <div className={css.hbox(12).alignItems("center")}>
115
- <h2 className={css.margin(0)}>Path Value Servers ({routableInfos.length})</h2>
139
+ <h2 className={css.margin(0)}>Path Value Servers ({serverInfos.length})</h2>
116
140
  <button className={buttonClass} onClick={() => invalidateRoutingTable()}>Invalidate</button>
117
141
  <button className={buttonClass} onClick={() => void clearAllPathStats(allNodeIds)}>Clear stats</button>
118
142
  </div>
119
143
  <div className={css.fontSize(13).colorhsl(0, 0, 45)}>
120
144
  Path values each server has received (↓) and sent (↑) since startup or the last clear. Expand a server for its top {PATH_SUMMARY_LIMIT} paths in each direction.
121
145
  </div>
122
- <div className={css.vbox(0).fillWidth.bord2(0, 0, 88)}>
123
- {routableInfos.map((info, i) => {
124
- let firstOfMachine = i === 0 || machineIdOf(routableInfos[i - 1].nodeId) !== machineIdOf(info.nodeId);
125
- return <AuthorityNodeRow key={info.nodeId} info={info} firstOfMachine={firstOfMachine} />;
126
- })}
146
+ <PathServerList infos={serverInfos} emptyText="(no path value servers)" />
147
+ <h2 className={css.margin(0)}>Other Senders ({otherInfos.length})</h2>
148
+ <div className={css.fontSize(13).colorhsl(0, 0, 45)}>
149
+ Nodes that aren't path value servers but still send/receive path values.
127
150
  </div>
151
+ <PathServerList infos={otherInfos} emptyText="(no other senders)" />
128
152
  </div>;
129
153
  }
130
154
  }
@@ -107,9 +107,6 @@ export const RoutingTablePageController = SocketFunction.register(
107
107
  () => ({
108
108
  hooks: [assertIsManagementUser],
109
109
  }),
110
- {
111
- noAutoExpose: true,
112
- }
113
110
  );
114
111
 
115
112
  export const RoutingTableSynced = getSyncedController(RoutingTablePageController, {
@@ -124,9 +124,23 @@ 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
- message: `Restoring will break all servers. This won't break your data, but you will have to restart all servers before the system will be stable again. Before then data will be random, and invalid writes might be allowed. It is recommend to run this with only 1 querysub server running, on a developers machine. Consider writing a database iterate script instead of restoring (similar to how gc and join work).`,
143
+ message: `Restoring will put the servers in a bad state. You're gonna need to restart all services after this.`,
130
144
  });
131
145
  if (!didConfirm) {
132
146
  console.log(red("Aborted restore"));
@@ -137,7 +151,6 @@ export class SnapshotViewer extends qreact.Component {
137
151
  overview: entry
138
152
  });
139
153
  console.log(green("Restored snapshot"));
140
- window.location.reload();
141
154
  }}>
142
155
  Restore
143
156
  </Button>
@@ -169,6 +182,26 @@ export class SnapshotViewer extends qreact.Component {
169
182
  }
170
183
  }
171
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
+
172
205
  async function downloadSnapshotFile(component: SnapshotViewer, file: string) {
173
206
  if (component.state.downloading[file]) return;
174
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
  }