telstore 0.1.9 → 0.1.10
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/README.md +74 -2
- package/bin/telstore.js +257 -4
- package/package.json +1 -1
- package/src/caption.js +6 -1
- package/src/cli.js +265 -10
- package/src/client.js +7 -1
- package/src/commands/delete.js +333 -41
- package/src/commands/down.js +311 -0
- package/src/commands/list.js +1 -31
- package/src/commands/restore-stream.js +407 -0
- package/src/commands/restore.js +27 -20
- package/src/commands/status.js +190 -19
- package/src/commands/upload-stream.js +459 -0
- package/src/commands/upload.js +37 -25
- package/src/commands/verify.js +1 -5
- package/src/manifest.js +53 -1
- package/src/progress.js +86 -0
- package/src/shell.js +33 -0
- package/src/spawn.js +38 -0
- package/src/state.js +78 -0
- package/src/stream.js +295 -0
- package/src/tar.js +23 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { MAX_CHUNKS, PART_SIZE } from '../chunking.js'
|
|
5
|
+
import { chunkCaption, manifestCaption, parseNote } from '../caption.js'
|
|
6
|
+
import { chatName, describeChat } from '../chat.js'
|
|
7
|
+
import {
|
|
8
|
+
MESSAGE_BATCH_SIZE,
|
|
9
|
+
closeQuietly,
|
|
10
|
+
connect as realConnect,
|
|
11
|
+
deleteMessages as realDeleteMessages,
|
|
12
|
+
} from '../client.js'
|
|
13
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
14
|
+
import {
|
|
15
|
+
buildManifest,
|
|
16
|
+
chunkFileName,
|
|
17
|
+
manifestFileName,
|
|
18
|
+
newBackupId,
|
|
19
|
+
serializeManifest,
|
|
20
|
+
} from '../manifest.js'
|
|
21
|
+
import { createStreamProgress, formatBytes, plural } from '../progress.js'
|
|
22
|
+
import { requireChat, resolveSettings } from '../settings.js'
|
|
23
|
+
import { deleteCommand } from '../shell.js'
|
|
24
|
+
import { spawnProducer } from '../spawn.js'
|
|
25
|
+
import {
|
|
26
|
+
MAX_STATES,
|
|
27
|
+
clearState,
|
|
28
|
+
markChunkDone,
|
|
29
|
+
pruneStates,
|
|
30
|
+
saveState,
|
|
31
|
+
streamKey,
|
|
32
|
+
tempDirFor,
|
|
33
|
+
} from '../state.js'
|
|
34
|
+
import { ChunkReader, discardChunkFile } from '../stream.js'
|
|
35
|
+
import { uploadRange } from '../uploader.js'
|
|
36
|
+
import { createOnRetry, realSendChunk, realSendManifest } from './upload.js'
|
|
37
|
+
|
|
38
|
+
// `telstore a.tar -- tar cf - ./a`: the backup's bytes are what the command writes, and the
|
|
39
|
+
// name is a label, not a file telstore reads.
|
|
40
|
+
//
|
|
41
|
+
// This is runUpload with the one thing it leans on taken away — a length known up front — so
|
|
42
|
+
// there is no planChunks, no resume, no re-stat, and no total on the bar. What replaces the
|
|
43
|
+
// re-stat is the child's exit code, which is the whole reason telstore spawns the command
|
|
44
|
+
// instead of reading a pipe.
|
|
45
|
+
export async function runStreamUpload(name, childArgv, options = {}, deps = {}) {
|
|
46
|
+
const {
|
|
47
|
+
connect = realConnect,
|
|
48
|
+
sendChunk = realSendChunk,
|
|
49
|
+
sendManifest = realSendManifest,
|
|
50
|
+
disconnect = (client) => client.destroy(),
|
|
51
|
+
configDir = defaultConfigDir(),
|
|
52
|
+
partSize = PART_SIZE,
|
|
53
|
+
// Through deps rather than read straight off the constant: this branch only fires after
|
|
54
|
+
// ten thousand chunks have gone out, and a limit a test cannot lower is a limit no test
|
|
55
|
+
// will ever reach.
|
|
56
|
+
maxChunks = MAX_CHUNKS,
|
|
57
|
+
spawn = spawnProducer,
|
|
58
|
+
deleteMessages = realDeleteMessages,
|
|
59
|
+
retryOptions = {},
|
|
60
|
+
writeErr = (line) => process.stderr.write(line),
|
|
61
|
+
log: writeLog = (line) => console.log(line),
|
|
62
|
+
silent = false,
|
|
63
|
+
onBackupId = () => {},
|
|
64
|
+
// How Ctrl-C reaches a run that must not be killed where it stands. See the call below.
|
|
65
|
+
onAbortable = () => {},
|
|
66
|
+
// Which chunk file this run is holding, so the one ending that does not come back through
|
|
67
|
+
// `discardChunkFile` can still take it with it. Said before the file is opened and unsaid
|
|
68
|
+
// after it is removed, so the caller's copy is never narrower than what is actually on disk.
|
|
69
|
+
onTempChunk = () => {},
|
|
70
|
+
} = deps
|
|
71
|
+
|
|
72
|
+
// Before the command is started, let alone connected to Telegram: the note is the one thing
|
|
73
|
+
// this run sends that a person typed by hand, and it goes into the manifest, which goes out
|
|
74
|
+
// last. A note Telegram would refuse has to stop the run here, not after an hour of pg_dump.
|
|
75
|
+
const note = parseNote(options.note)
|
|
76
|
+
|
|
77
|
+
const config = await loadConfig(configDir)
|
|
78
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
79
|
+
const chat = requireChat(settings)
|
|
80
|
+
const chunkSize = settings.chunkSize
|
|
81
|
+
const concurrency = settings.uploadConcurrency
|
|
82
|
+
|
|
83
|
+
const id = newBackupId()
|
|
84
|
+
const key = streamKey(id)
|
|
85
|
+
|
|
86
|
+
// A stream record exists for one reason: to say what is already in the chat when the run
|
|
87
|
+
// fails, because nothing else will ever point at those chunks. There is no path, size or
|
|
88
|
+
// mtime to key it on and nothing to resume onto — `kind` is what tells status and delete so.
|
|
89
|
+
let state = { v: 1, kind: 'stream', id, chat: String(chat), name, chunkSize, done: {} }
|
|
90
|
+
|
|
91
|
+
await saveState(key, state, configDir)
|
|
92
|
+
|
|
93
|
+
// Every stream upload adds to the directory, so this is where it can grow. The report goes
|
|
94
|
+
// out even when the caller asked for silence: this is not narration about a transfer, it is
|
|
95
|
+
// telstore dropping the only record of someone else's chunks.
|
|
96
|
+
for (const gone of await pruneStates(configDir)) {
|
|
97
|
+
writeErr(
|
|
98
|
+
`\nDropped the record of unfinished backup ${gone.id}: telstore keeps the ` +
|
|
99
|
+
`${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
|
|
100
|
+
'searchable by that id, but that backup can no longer be resumed.\n',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
onBackupId(id)
|
|
105
|
+
|
|
106
|
+
// The producer and the reader are named here rather than where they are made, because the
|
|
107
|
+
// abort below has to be handed out before either exists.
|
|
108
|
+
let aborted = false
|
|
109
|
+
let child = null
|
|
110
|
+
let reader = null
|
|
111
|
+
|
|
112
|
+
// A run whose chunks are removed when it fails cannot be killed where it stands: exiting at
|
|
113
|
+
// the signal would leave in the chat exactly the chunks this command promises never to
|
|
114
|
+
// leave, and removing them is a network round trip per batch. So Ctrl-C asks the run to
|
|
115
|
+
// stop instead, through this, and waits for the rollback below to finish.
|
|
116
|
+
//
|
|
117
|
+
// Handed over before connect, not after the child is spawned: from the moment the record
|
|
118
|
+
// exists there is something a Ctrl-C has to unwind, and a caller that has not been given
|
|
119
|
+
// this yet has no choice but to exit on the spot.
|
|
120
|
+
onAbortable(async () => {
|
|
121
|
+
aborted = true
|
|
122
|
+
|
|
123
|
+
// The kill stops the producer; closing the reader is what unblocks a fill still waiting
|
|
124
|
+
// on a pipe the child is never going to write to again.
|
|
125
|
+
if (child) child.kill()
|
|
126
|
+
if (reader) await reader.close()
|
|
127
|
+
}, { chat })
|
|
128
|
+
|
|
129
|
+
// The one error the caller is meant to recognise: a run that stopped because someone asked
|
|
130
|
+
// it to has nothing to report that Ctrl-C did not already say. A rollback that could not
|
|
131
|
+
// finish throws a fresh error of its own instead, and that one is still a failure.
|
|
132
|
+
function stopped() {
|
|
133
|
+
const err = new Error('Stopped before the backup was finished.')
|
|
134
|
+
err.interrupted = true
|
|
135
|
+
return err
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const log = silent ? () => {} : writeLog
|
|
139
|
+
const warn = silent ? () => {} : writeErr
|
|
140
|
+
const onRetry = createOnRetry(warn)
|
|
141
|
+
|
|
142
|
+
// Every message id this run has put in the chat, in the order it put them there. The record
|
|
143
|
+
// on disk is the durable copy, for the run that dies without getting this far; this one is
|
|
144
|
+
// what rollback reaches for, because it is right even when the write to disk is the thing
|
|
145
|
+
// that failed.
|
|
146
|
+
const sent = []
|
|
147
|
+
let client = null
|
|
148
|
+
|
|
149
|
+
// A file upload keeps its chunks on purpose — a second run resumes onto them. A stream
|
|
150
|
+
// cannot be resumed: the bytes have gone past, and the next run cuts them differently. So a
|
|
151
|
+
// chunk left in the chat by a failed stream is a chunk nothing will ever point at again,
|
|
152
|
+
// sitting in somebody's Telegram with no manifest naming it. Removing them is part of
|
|
153
|
+
// failing, not a courtesy. Always throws.
|
|
154
|
+
async function rollback(err) {
|
|
155
|
+
if (sent.length === 0) {
|
|
156
|
+
// Cleared even when it names nothing. An empty record is useless, but it still counts
|
|
157
|
+
// against MAX_STATES exactly as a full one does — so leaving it behind can evict the
|
|
158
|
+
// record of a real upload whose chunks are still in a chat, which is the loss
|
|
159
|
+
// pruneStates goes out of its way to announce.
|
|
160
|
+
await clearState(key, configDir)
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// "message" rather than "chunk": the manifest joins this list on the narrow path where a
|
|
165
|
+
// run fails after sending it, and a count that says "3 chunks" for two chunks and a card
|
|
166
|
+
// is telstore describing the chat wrongly in the one report someone reads closely.
|
|
167
|
+
warn(`\nRemoving the ${plural(sent.length, 'message')} this run already sent...\n`)
|
|
168
|
+
|
|
169
|
+
const loud = sent.length > MESSAGE_BATCH_SIZE
|
|
170
|
+
let removed = 0
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await deleteMessages(client, chat, sent, {
|
|
174
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
175
|
+
onBatch: (done, total) => {
|
|
176
|
+
removed = done
|
|
177
|
+
if (loud) warn(`\rRemoving messages ${done}/${total}…`)
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
} catch (cleanupErr) {
|
|
181
|
+
// The record stays, and deliberately: it is the only list of these message ids, since
|
|
182
|
+
// there is no manifest in the chat and there never will be one. `delete` reads exactly
|
|
183
|
+
// this record through findStates when it finds no manifest, which is why that is the
|
|
184
|
+
// command to name. The original failure is still said first — the rollback is what
|
|
185
|
+
// happened next, not what went wrong.
|
|
186
|
+
//
|
|
187
|
+
// The chat is always named, where `status` leaves --chat out of a *resume* when it
|
|
188
|
+
// matches the destination in force. status compares against the destination its own run
|
|
189
|
+
// resolved, which is the one the pasted command will resolve too. Here the destination
|
|
190
|
+
// in force may have come from a --chat on this command line, which the later `delete`
|
|
191
|
+
// will not carry: it would resolve its own chat from config and fire these ids at that
|
|
192
|
+
// peer instead, destroying whatever happens to carry them there. `deleteCommand` holds
|
|
193
|
+
// that rule for every place that prints this line; `chat` is required by the time a run
|
|
194
|
+
// has sent anything, so the chatless branch is not reachable from here.
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${err.message}\n\ntelstore then removed ${removed} of the ` +
|
|
197
|
+
`${plural(sent.length, 'message')} it had sent before Telegram refused: ` +
|
|
198
|
+
`${cleanupErr.message}. The rest are still in ${chatName(chat)}. The local record ` +
|
|
199
|
+
'was left in place on purpose — it is the only list of them on this machine. Run ' +
|
|
200
|
+
`"${deleteCommand(id, chat)}" to remove them.`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (loud) warn('\n')
|
|
205
|
+
|
|
206
|
+
await clearState(key, configDir)
|
|
207
|
+
|
|
208
|
+
throw err
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
log(`Backup ${id}`)
|
|
212
|
+
log(`Name ${name} (chunks of ${formatBytes(chunkSize)})`)
|
|
213
|
+
log(`From ${childArgv.join(' ')}`)
|
|
214
|
+
log(`To ${describeChat(chat)}\n`)
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
client = await connect(config, { verbose: settings.verbose })
|
|
218
|
+
|
|
219
|
+
// Connecting is the one stretch long enough for Ctrl-C to arrive before the command has
|
|
220
|
+
// been started, and starting someone's command after they asked telstore to stop is the
|
|
221
|
+
// one thing a wait must not turn into.
|
|
222
|
+
if (aborted) throw stopped()
|
|
223
|
+
|
|
224
|
+
const tmp = tempDirFor(configDir)
|
|
225
|
+
await fs.mkdir(tmp, { recursive: true })
|
|
226
|
+
|
|
227
|
+
child = spawn(childArgv)
|
|
228
|
+
reader = new ChunkReader(child.stdout)
|
|
229
|
+
|
|
230
|
+
let size = 0
|
|
231
|
+
let count = 0
|
|
232
|
+
let ended = false
|
|
233
|
+
let progress = null
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
try {
|
|
237
|
+
for (;;) {
|
|
238
|
+
const file = path.join(tmp, `${id}-${count}.chunk`)
|
|
239
|
+
|
|
240
|
+
// Said before the open rather than after it, so the handler holds the name for the
|
|
241
|
+
// whole window in which a file can exist: 'w+' creates it, and a second Ctrl-C
|
|
242
|
+
// landing while the open is in flight would otherwise find nothing to remove.
|
|
243
|
+
// Unlinking a file that was never made is an ENOENT the caller ignores.
|
|
244
|
+
//
|
|
245
|
+
// What this does not cover, and it looks as though it should: an open that creates
|
|
246
|
+
// the file and then throws leaves the loop without entering the `try` below, so the
|
|
247
|
+
// `finally` that removes it never runs and nothing on that path reads the name back.
|
|
248
|
+
// `status` is what catches that one, with every other ending no handler sees.
|
|
249
|
+
onTempChunk(file)
|
|
250
|
+
|
|
251
|
+
const handle = await fs.open(file, 'w+')
|
|
252
|
+
let eof = false
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
// Nothing pulls ahead of this call, which is what stops the child running away
|
|
256
|
+
// with the pipe while a chunk spends three minutes uploading: the backlog waits
|
|
257
|
+
// in the kernel's buffer and in the child, not in this process's memory.
|
|
258
|
+
const filled = await reader.fill(handle, chunkSize)
|
|
259
|
+
eof = filled.eof
|
|
260
|
+
|
|
261
|
+
// An abort that lands while this fill was waiting must not become one more chunk
|
|
262
|
+
// in the chat. The rollback below would remove it again, but not before minutes
|
|
263
|
+
// of uploading had gone by with the bar still moving, in front of the person who
|
|
264
|
+
// asked telstore to stop.
|
|
265
|
+
if (aborted) throw stopped()
|
|
266
|
+
|
|
267
|
+
if (filled.bytes > 0) {
|
|
268
|
+
// Asked of bytes that actually arrived, not of the count alone. A stream that
|
|
269
|
+
// ends exactly on a chunk boundary reports eof only on the fill after it, so a
|
|
270
|
+
// check before the fill would refuse a backup of exactly maxChunks chunks —
|
|
271
|
+
// one the file path builds happily.
|
|
272
|
+
//
|
|
273
|
+
// Checked here rather than before the first byte because there is no length to
|
|
274
|
+
// count chunks from, and a stream cannot be re-cut: the way out is a bigger
|
|
275
|
+
// chunk on the next run, not a resume of this one.
|
|
276
|
+
if (count >= maxChunks) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`This command has already produced ${maxChunks} chunks of ` +
|
|
279
|
+
`${formatBytes(chunkSize)} and has not finished, which is as many as a ` +
|
|
280
|
+
'backup holds. Run again with a larger --chunk-size.',
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const fileName = chunkFileName(id, count)
|
|
285
|
+
|
|
286
|
+
// No bar until there is something to draw one for. A command that writes
|
|
287
|
+
// nothing would otherwise get a bar springing into existence at zero, only to
|
|
288
|
+
// be told a moment later that there is no backup to make.
|
|
289
|
+
if (progress === null) {
|
|
290
|
+
progress = createStreamProgress({ label: `Chunk ${count + 1}`, write: warn })
|
|
291
|
+
} else {
|
|
292
|
+
progress.setLabel(`Chunk ${count + 1}`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Offset 0 of a file holding exactly this chunk: uploadRange neither knows nor
|
|
296
|
+
// cares that the bytes arrived through a pipe rather than off a disk.
|
|
297
|
+
const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
|
|
298
|
+
offset: 0,
|
|
299
|
+
length: filled.bytes,
|
|
300
|
+
fileName,
|
|
301
|
+
concurrency,
|
|
302
|
+
partSize,
|
|
303
|
+
onProgress: (bytes) => progress.advance(bytes),
|
|
304
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const message = await sendChunk(client, chat, {
|
|
308
|
+
inputFile,
|
|
309
|
+
fileName,
|
|
310
|
+
// A stream knows the number and not the count. The manifest card carries the
|
|
311
|
+
// total once there finally is one.
|
|
312
|
+
caption: chunkCaption({ id, number: count + 1, total: null }),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Before the record is written, not after: a chunk is in the chat the instant
|
|
316
|
+
// sendChunk returns, and a saveState that throws must not be what hides it
|
|
317
|
+
// from the rollback that is about to run.
|
|
318
|
+
sent.push(message.id)
|
|
319
|
+
|
|
320
|
+
// Recorded the moment it lands, because from here on this record is the only
|
|
321
|
+
// list of what is in the chat under this id.
|
|
322
|
+
state = await markChunkDone(
|
|
323
|
+
key,
|
|
324
|
+
state,
|
|
325
|
+
count,
|
|
326
|
+
{ msgId: message.id, size: filled.bytes, sha256 },
|
|
327
|
+
configDir,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
size += filled.bytes
|
|
331
|
+
count += 1
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
await discardChunkFile(handle, file, { writeErr, chunkSize })
|
|
335
|
+
|
|
336
|
+
// Unsaid whether the removal worked or not. If it did there is nothing left to
|
|
337
|
+
// remove; if it did not, `discardChunkFile` has already named the file on stderr,
|
|
338
|
+
// and a second attempt from the signal handler would say it twice.
|
|
339
|
+
onTempChunk(null)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (eof) break
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
// Same reason as runUpload: a send that fails must not leave "Error: ..." printed
|
|
346
|
+
// over the bar's own line.
|
|
347
|
+
progress?.finish()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The stream's answer to the file path's re-stat, and the reason telstore spawns the
|
|
351
|
+
// command rather than reading a pipe. An EOF after a crash and an EOF after success are
|
|
352
|
+
// the same event on this end of the pipe; the exit code is the only thing that tells
|
|
353
|
+
// them apart, and a manifest sent without it would describe a truncated archive that
|
|
354
|
+
// restores perfectly and is garbage.
|
|
355
|
+
const { code, signal } = await child.exited
|
|
356
|
+
ended = true
|
|
357
|
+
|
|
358
|
+
if (code !== 0 || signal !== null) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
signal !== null
|
|
361
|
+
? `${childArgv[0]} was killed by ${signal} after writing ${formatBytes(size)}. ` +
|
|
362
|
+
'telstore is not sending the manifest: what it wrote is an unfinished file.'
|
|
363
|
+
: `${childArgv[0]} exited ${code} after writing ${formatBytes(size)}. ` +
|
|
364
|
+
'telstore is not sending the manifest: what it wrote is an unfinished file.',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// A clean exit having written nothing is usually a command whose arguments were wrong,
|
|
369
|
+
// and a backup of nothing is not a backup — restore would have nothing to write.
|
|
370
|
+
if (size === 0) {
|
|
371
|
+
throw new Error(`${childArgv[0]} wrote nothing, so there is no backup to make.`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const manifest = buildManifest({
|
|
375
|
+
id,
|
|
376
|
+
name,
|
|
377
|
+
size,
|
|
378
|
+
chunkSize,
|
|
379
|
+
note,
|
|
380
|
+
chunks: Array.from({ length: count }, (_, i) => ({ i, ...state.done[String(i)] })),
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const card = await sendManifest(client, chat, {
|
|
384
|
+
bytes: serializeManifest(manifest),
|
|
385
|
+
fileName: manifestFileName(id),
|
|
386
|
+
caption: manifestCaption({
|
|
387
|
+
id: manifest.id,
|
|
388
|
+
name: manifest.name,
|
|
389
|
+
size: manifest.size,
|
|
390
|
+
chunks: manifest.chunks.length,
|
|
391
|
+
createdAt: manifest.createdAt,
|
|
392
|
+
note: manifest.note ?? null,
|
|
393
|
+
}),
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// The manifest is a message this run put in the chat like any other. Anything that
|
|
397
|
+
// fails after this line — the record write below, the closing line written into a pipe
|
|
398
|
+
// that has gone away — still rolls back, and a rollback that took the chunks but left
|
|
399
|
+
// this would leave a backup `list` advertises and `restore` cannot fulfil. Pushed last
|
|
400
|
+
// so it is removed last, the order `delete` keeps for the same reason: the manifest is
|
|
401
|
+
// the only index of the ids under it, so it is the one thing worth having if a removal
|
|
402
|
+
// stops halfway.
|
|
403
|
+
sent.push(card.id)
|
|
404
|
+
|
|
405
|
+
// And into the record, before that record can be left behind by a rollback that could
|
|
406
|
+
// not finish. `sent` is this process's memory and dies with it; the record is what
|
|
407
|
+
// `delete` reads afterwards, and the only other way it could find this card is
|
|
408
|
+
// `findManifestMessage`, which asks Telegram's text index — docs/design/captions.md
|
|
409
|
+
// records that index returning nothing for a channel whose documents were all plainly
|
|
410
|
+
// there, and nothing predicts when it happens. A delete that cannot find the manifest
|
|
411
|
+
// takes the chunks and leaves the card behind advertising a backup restore cannot
|
|
412
|
+
// fulfil. Written after the push, for the same reason the chunk ids are: a saveState
|
|
413
|
+
// that throws must not be what hides this message from the rollback about to run.
|
|
414
|
+
state = { ...state, manifestMsgId: card.id }
|
|
415
|
+
await saveState(key, state, configDir)
|
|
416
|
+
|
|
417
|
+
log(`\nDone. Restore with:\n npx telstore restore ${id}`)
|
|
418
|
+
|
|
419
|
+
// Cleared after the closing line rather than before it, which is what gives the write
|
|
420
|
+
// above anything to protect. Writing that line is a real thing that fails — `telstore …
|
|
421
|
+
// | head` closes the pipe under telstore's feet — and it rolls the run back. A record
|
|
422
|
+
// cleared a moment earlier would leave a rollback that Telegram then refuses with the
|
|
423
|
+
// chunks and the card still in the chat and nothing on this machine naming any of them.
|
|
424
|
+
await clearState(key, configDir)
|
|
425
|
+
|
|
426
|
+
return { id, chunks: count, size }
|
|
427
|
+
} finally {
|
|
428
|
+
// A run that fell over mid-chunk leaves the producer alive and blocked writing into a
|
|
429
|
+
// pipe nobody is reading. Killing it is not rollback — it is closing the door this
|
|
430
|
+
// function opened, and the door is more than the process: the abandoned iterator still
|
|
431
|
+
// holds stdout, so a child that outlives the signal goes on waiting on a pipe whose
|
|
432
|
+
// reader is never coming back. Returning the iterator releases it and destroys the
|
|
433
|
+
// stream, which turns that wait into an EPIPE the child can actually act on.
|
|
434
|
+
if (!ended) {
|
|
435
|
+
child.kill()
|
|
436
|
+
await reader.close()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (err) {
|
|
440
|
+
// Whatever an abort surfaced as — a fill rejecting on a destroyed pipe, a producer that
|
|
441
|
+
// died on the signal, a stall that never came back — the reason this run stopped is the
|
|
442
|
+
// Ctrl-C, and saying so is what lets the caller leave with 130 instead of reporting a
|
|
443
|
+
// failure nobody had.
|
|
444
|
+
//
|
|
445
|
+
// Always throws: the error itself once the chat is clean again, or a report of the
|
|
446
|
+
// rollback that could not finish.
|
|
447
|
+
await rollback(aborted ? stopped() : err)
|
|
448
|
+
} finally {
|
|
449
|
+
// connect is inside the try now, because a rollback needs a live client and the finally
|
|
450
|
+
// that closes one has to run after it. So a connect that failed leaves nothing to close,
|
|
451
|
+
// and a warning about closing a client that never existed would bury the real reason the
|
|
452
|
+
// run stopped.
|
|
453
|
+
if (client) {
|
|
454
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
455
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
package/src/commands/upload.js
CHANGED
|
@@ -34,12 +34,40 @@ import {
|
|
|
34
34
|
import { uploadRange } from '../uploader.js'
|
|
35
35
|
|
|
36
36
|
// Above this threshold the wait must be spelled out, per spec §8.
|
|
37
|
-
const LONG_WAIT_MS = 60_000
|
|
37
|
+
export const LONG_WAIT_MS = 60_000
|
|
38
38
|
|
|
39
39
|
// A transient error that resolves itself on the next try is not news, and one line per
|
|
40
40
|
// occurrence buries the progress bar in a wall of text. Stay quiet until the third retry:
|
|
41
41
|
// by then the trouble has outlived two backoffs and is worth saying out loud.
|
|
42
|
-
const ANNOUNCE_AFTER_ATTEMPT = 3
|
|
42
|
+
export const ANNOUNCE_AFTER_ATTEMPT = 3
|
|
43
|
+
|
|
44
|
+
// Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
|
|
45
|
+
// staring at a frozen progress bar for an hour, assuming the process has hung.
|
|
46
|
+
//
|
|
47
|
+
// Exported because a stream upload waits on the same Telegram and has to say the same
|
|
48
|
+
// things about it. Two copies of this wording would drift, and the one that drifted would
|
|
49
|
+
// be the one nobody was reading at the time.
|
|
50
|
+
export function createOnRetry(warn) {
|
|
51
|
+
return function onRetry(err, attempt, delayMs, elapsedMs = 0) {
|
|
52
|
+
if (delayMs > LONG_WAIT_MS) {
|
|
53
|
+
warn(
|
|
54
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
|
|
55
|
+
`(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The exception to staying quiet: an attempt that took a minute to fail spent that
|
|
61
|
+
// minute with the bar frozen, which is exactly what a hang looks like. Those are worth
|
|
62
|
+
// a line the first time, whatever the attempt number.
|
|
63
|
+
if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
|
|
64
|
+
|
|
65
|
+
warn(
|
|
66
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
67
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
43
71
|
|
|
44
72
|
// Chunks and manifests differ only in where the bytes come from. Everything Telegram is
|
|
45
73
|
// told about them — document, not preview; this exact file name — is decided once.
|
|
@@ -52,11 +80,15 @@ async function sendDocument(client, peer, { file, fileName, caption }) {
|
|
|
52
80
|
})
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
|
|
83
|
+
// The defaults behind runUpload's `sendChunk` and `sendManifest` deps. Exported because a
|
|
84
|
+
// stream upload sends the same two kinds of document to the same Telegram, and a second copy
|
|
85
|
+
// of "document, not preview; this exact file name" is how the two start disagreeing about
|
|
86
|
+
// what telstore actually put in the chat.
|
|
87
|
+
export async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
|
|
56
88
|
return await sendDocument(client, peer, { file: inputFile, fileName, caption })
|
|
57
89
|
}
|
|
58
90
|
|
|
59
|
-
async function realSendManifest(client, peer, { bytes, fileName, caption }) {
|
|
91
|
+
export async function realSendManifest(client, peer, { bytes, fileName, caption }) {
|
|
60
92
|
return await sendDocument(client, peer, {
|
|
61
93
|
file: new CustomFile(fileName, bytes.length, '', bytes),
|
|
62
94
|
fileName,
|
|
@@ -224,27 +256,7 @@ export async function runUpload(filePath, options = {}, deps = {}) {
|
|
|
224
256
|
const log = silent ? () => {} : writeLog
|
|
225
257
|
const warn = silent ? () => {} : writeErr
|
|
226
258
|
|
|
227
|
-
|
|
228
|
-
// staring at a frozen progress bar for an hour, assuming the process has hung.
|
|
229
|
-
function onRetry(err, attempt, delayMs, elapsedMs = 0) {
|
|
230
|
-
if (delayMs > LONG_WAIT_MS) {
|
|
231
|
-
warn(
|
|
232
|
-
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
|
|
233
|
-
`(${err.message}). telstore is waiting and will carry on by itself, leave it running.\n`,
|
|
234
|
-
)
|
|
235
|
-
return
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// The exception to staying quiet: an attempt that took a minute to fail spent that
|
|
239
|
-
// minute with the bar frozen, which is exactly what a hang looks like. Those are worth
|
|
240
|
-
// a line the first time, whatever the attempt number.
|
|
241
|
-
if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
|
|
242
|
-
|
|
243
|
-
warn(
|
|
244
|
-
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
245
|
-
`${formatDuration(delayMs / 1000)}.\n`,
|
|
246
|
-
)
|
|
247
|
-
}
|
|
259
|
+
const onRetry = createOnRetry(warn)
|
|
248
260
|
|
|
249
261
|
log(`Backup ${state.id}`)
|
|
250
262
|
log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
|
package/src/commands/verify.js
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from '../client.js'
|
|
12
12
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
13
13
|
import { chunkFileName, manifestFileName, parseManifest } from '../manifest.js'
|
|
14
|
-
import { formatBytes, formatDuration } from '../progress.js'
|
|
14
|
+
import { formatBytes, formatDuration, plural } from '../progress.js'
|
|
15
15
|
import { assertLoggedIn } from '../session.js'
|
|
16
16
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
17
17
|
|
|
@@ -21,10 +21,6 @@ function describeName(name) {
|
|
|
21
21
|
return typeof name === 'string' && name.trim() !== '' ? name : '—'
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function plural(n, word) {
|
|
25
|
-
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
24
|
// What is wrong with one chunk, or null when nothing is. The first failing check wins: a
|
|
29
25
|
// chunk is damaged or it is not, and listing three complaints about one message would make
|
|
30
26
|
// "2 damaged" mean something other than two chunks.
|
package/src/manifest.js
CHANGED
|
@@ -11,8 +11,60 @@ export function newBackupId(now = new Date(), randomHex = () => randomBytes(3).t
|
|
|
11
11
|
return `telstore-${yyyy}${mm}${dd}-${randomHex()}`
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// The day a backup id carries, as the UTC second that day began. newBackupId stamps it above
|
|
15
|
+
// from the clock of the machine making the backup, so it is that machine's idea of the day
|
|
16
|
+
// rather than Telegram's — which is why the one reader of this (delete's walk of the chat)
|
|
17
|
+
// gives it a day of slack and only ever uses it as a floor.
|
|
18
|
+
//
|
|
19
|
+
// A date that does not exist is not a day: `telstore-20269999-abc` would otherwise roll over
|
|
20
|
+
// into a year's time and read as a floor above everything in the chat, which is an early stop
|
|
21
|
+
// nobody would see. Null instead, and the caller falls back to a bound it can prove.
|
|
22
|
+
const BACKUP_ID_DAY = /^telstore-(\d{4})(\d{2})(\d{2})-[0-9a-f]+$/
|
|
23
|
+
|
|
24
|
+
export function backupIdDay(id) {
|
|
25
|
+
const match = BACKUP_ID_DAY.exec(String(id))
|
|
26
|
+
|
|
27
|
+
if (!match) return null
|
|
28
|
+
|
|
29
|
+
const [year, month, day] = match.slice(1).map(Number)
|
|
30
|
+
const at = new Date(Date.UTC(year, month - 1, day))
|
|
31
|
+
|
|
32
|
+
if (at.getUTCFullYear() !== year || at.getUTCMonth() !== month - 1 || at.getUTCDate() !== day) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Math.floor(at.getTime() / 1000)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The infix in every chunk's file name. A constant rather than a literal for the same reason
|
|
40
|
+
// MANIFEST_SUFFIX is one: there are two readers of that name now — the writer below and
|
|
41
|
+
// isChunkFileName — and a reader that disagrees with the writer by one character finds
|
|
42
|
+
// nothing at all.
|
|
43
|
+
const CHUNK_INFIX = '.part'
|
|
44
|
+
|
|
14
45
|
export function chunkFileName(id, i) {
|
|
15
|
-
return `${id}
|
|
46
|
+
return `${id}${CHUNK_INFIX}${String(i + 1).padStart(4, '0')}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Whether a document in a chat is a chunk of this backup, decided by the file name telstore
|
|
50
|
+
// itself wrote and not by the caption beside it — the rule findManifestMessage already keeps,
|
|
51
|
+
// for the same reason: a caption is text a person can edit and a file name is not.
|
|
52
|
+
//
|
|
53
|
+
// The number is checked but never read back. What the caller needs is which backup a document
|
|
54
|
+
// belongs to, and a chunk whose index says something impossible is still that backup's chunk.
|
|
55
|
+
// What the check is for is the other direction: without it `<id>.partial` or `<id>.part.bak`
|
|
56
|
+
// — names telstore never writes, but names a person can give a file they upload themselves —
|
|
57
|
+
// would be read as chunks of a backup and destroyed along with it.
|
|
58
|
+
export function isChunkFileName(id, fileName) {
|
|
59
|
+
if (typeof fileName !== 'string') return false
|
|
60
|
+
|
|
61
|
+
const prefix = `${id}${CHUNK_INFIX}`
|
|
62
|
+
|
|
63
|
+
if (!fileName.startsWith(prefix)) return false
|
|
64
|
+
|
|
65
|
+
const number = fileName.slice(prefix.length)
|
|
66
|
+
|
|
67
|
+
return number.length > 0 && /^[0-9]+$/.test(number)
|
|
16
68
|
}
|
|
17
69
|
|
|
18
70
|
// The suffix telstore has written on every manifest since version 1, and what `list` picks
|