@wangxiaocuo/dsh-session-trash 0.6.0 → 0.6.2
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.en.md +14 -11
- package/README.md +14 -11
- package/client.js +258 -75
- package/lib/concurrency.js +30 -0
- package/lib/inventory.js +58 -25
- package/lib/state.js +1 -21
- package/lib/trash-service.js +241 -88
- package/package.json +8 -3
package/lib/inventory.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { mapLimit } from './concurrency.js'
|
|
3
4
|
import { entryKind, isSafeDirectoryPath, isSafeMemberId, isSafeTrashId, samePath } from './fsops.js'
|
|
5
|
+
import { memberList } from './members.js'
|
|
6
|
+
|
|
7
|
+
/** Maximum concurrent object directories inspected during a scan. */
|
|
8
|
+
const SCAN_CONCURRENCY = 4
|
|
4
9
|
|
|
5
10
|
/** Every state the durable index understands. */
|
|
6
11
|
export const ENTRY_STATES = new Set(['trashing', 'trashed', 'restoring', 'purging', 'missing'])
|
|
@@ -220,6 +225,22 @@ export async function memberPresence(store, entry) {
|
|
|
220
225
|
return { total: members.length, present, missing }
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Whether two member lists name the same directories in the same order, so a
|
|
230
|
+
* presence probe taken for one can be reused for the other.
|
|
231
|
+
*
|
|
232
|
+
* @param {readonly object[]} left first member list.
|
|
233
|
+
* @param {readonly object[]} right second member list.
|
|
234
|
+
* @returns {boolean} whether both lists describe the same members.
|
|
235
|
+
*/
|
|
236
|
+
function sameMembers(left, right) {
|
|
237
|
+
if (left.length !== right.length) return false
|
|
238
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
239
|
+
if (left[index]?.sessionId !== right[index]?.sessionId) return false
|
|
240
|
+
}
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
223
244
|
/**
|
|
224
245
|
* Reconcile a rebuilt entry with the object directory observed on disk.
|
|
225
246
|
*
|
|
@@ -271,13 +292,8 @@ export async function scanObjects(store, logger) {
|
|
|
271
292
|
if (error !== null && error !== undefined && error.code === 'ENOENT') return { objects: [], unindexed: 0 }
|
|
272
293
|
throw error
|
|
273
294
|
}
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
for (const entry of entries) {
|
|
277
|
-
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) {
|
|
278
|
-
unindexed += 1
|
|
279
|
-
continue
|
|
280
|
-
}
|
|
295
|
+
const scanned = await mapLimit(entries, SCAN_CONCURRENCY, async (entry) => {
|
|
296
|
+
if (!entry.isDirectory() || !isSafeTrashId(entry.name)) return { unindexed: 1 }
|
|
281
297
|
const trashId = entry.name
|
|
282
298
|
let meta
|
|
283
299
|
try {
|
|
@@ -286,15 +302,11 @@ export async function scanObjects(store, logger) {
|
|
|
286
302
|
logger.warn(`cannot read the trash metadata for ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
287
303
|
// Keep the directory in the inventory so reconcile never mistakes an
|
|
288
304
|
// unreadable sidecar for a vanished object directory.
|
|
289
|
-
|
|
290
|
-
unindexed += 1
|
|
291
|
-
continue
|
|
305
|
+
return { object: { trashId, sessionExists: false, meta: undefined, metaValid: false }, unindexed: 1 }
|
|
292
306
|
}
|
|
293
307
|
const metaValid = meta !== undefined && isValidMeta(meta, trashId)
|
|
294
308
|
if (!metaValid) {
|
|
295
|
-
|
|
296
|
-
unindexed += 1
|
|
297
|
-
continue
|
|
309
|
+
return { object: { trashId, sessionExists: false, meta, metaValid: false }, unindexed: 1 }
|
|
298
310
|
}
|
|
299
311
|
let presence
|
|
300
312
|
try {
|
|
@@ -303,14 +315,23 @@ export async function scanObjects(store, logger) {
|
|
|
303
315
|
logger.warn(`cannot inspect the trash copy ${trashId}: ${error instanceof Error ? error.message : String(error)}`)
|
|
304
316
|
presence = { total: 0, present: 0, missing: [] }
|
|
305
317
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
318
|
+
return {
|
|
319
|
+
object: {
|
|
320
|
+
trashId,
|
|
321
|
+
sessionExists: presence.total > 0 && presence.present === presence.total,
|
|
322
|
+
meta,
|
|
323
|
+
metaValid,
|
|
324
|
+
entry: toEntry(meta),
|
|
325
|
+
presence,
|
|
326
|
+
},
|
|
327
|
+
unindexed: 0,
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
const objects = []
|
|
331
|
+
let unindexed = 0
|
|
332
|
+
for (const result of scanned) {
|
|
333
|
+
unindexed += result.unindexed
|
|
334
|
+
if (result.object !== undefined) objects.push(result.object)
|
|
314
335
|
}
|
|
315
336
|
return { objects, unindexed }
|
|
316
337
|
}
|
|
@@ -321,12 +342,15 @@ export async function scanObjects(store, logger) {
|
|
|
321
342
|
* directory.
|
|
322
343
|
*
|
|
323
344
|
* @param {{ store: import('./store.js').TrashStore, logger: { warn: Function }, now?: number }} input trash store and diagnostics.
|
|
324
|
-
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number }>} reconcile summary.
|
|
345
|
+
* @returns {Promise<{ imported: number, markedMissing: number, unindexed: number, changed: number, presence: Map<string, object> }>} reconcile summary plus the presence probe per object.
|
|
325
346
|
*/
|
|
326
347
|
export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
327
|
-
if (store.isProtected())
|
|
348
|
+
if (store.isProtected()) {
|
|
349
|
+
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
|
|
350
|
+
}
|
|
328
351
|
const { objects, unindexed: invalidObjects } = await scanObjects(store, logger)
|
|
329
352
|
const byTrashId = new Map(objects.map((object) => [object.trashId, object]))
|
|
353
|
+
const presenceByTrashId = new Map()
|
|
330
354
|
const nextEntries = [...store.entries()]
|
|
331
355
|
const indexedTrashIds = new Set(nextEntries.map((entry) => entry.trashId))
|
|
332
356
|
const sessionIds = new Set(nextEntries.map((entry) => entry.sessionId))
|
|
@@ -351,7 +375,16 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
351
375
|
changed = true
|
|
352
376
|
continue
|
|
353
377
|
}
|
|
354
|
-
|
|
378
|
+
// The scan already probed every member directory of this object. Reuse that
|
|
379
|
+
// answer when the sidecar and the index entry describe the same members;
|
|
380
|
+
// otherwise probe again rather than trust a mismatched snapshot.
|
|
381
|
+
const presence =
|
|
382
|
+
object.presence !== undefined &&
|
|
383
|
+
object.entry !== undefined &&
|
|
384
|
+
sameMembers(memberList(object.entry), memberList(entry))
|
|
385
|
+
? object.presence
|
|
386
|
+
: await memberPresence(store, entry)
|
|
387
|
+
presenceByTrashId.set(object.trashId, presence)
|
|
355
388
|
if (entry.state === 'missing' && presence.total > 0 && presence.present === presence.total) {
|
|
356
389
|
nextEntries[index] = {
|
|
357
390
|
...entry,
|
|
@@ -392,7 +425,7 @@ export async function reconcileIndex({ store, logger, now = Date.now() }) {
|
|
|
392
425
|
}
|
|
393
426
|
}
|
|
394
427
|
store.unindexed = unindexed
|
|
395
|
-
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0 }
|
|
428
|
+
return { imported, markedMissing, unindexed, changed: changed ? 1 : 0, presence: presenceByTrashId }
|
|
396
429
|
}
|
|
397
430
|
|
|
398
431
|
/**
|
package/lib/state.js
CHANGED
|
@@ -1,30 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import { mapLimit } from './concurrency.js'
|
|
2
3
|
import { memberList } from './members.js'
|
|
3
4
|
|
|
4
5
|
/** Maximum concurrent filesystem probes while describing entries. */
|
|
5
6
|
const PROBE_CONCURRENCY = 3
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
* @param {readonly T[]} items input items.
|
|
9
|
-
* @param {number} limit maximum in-flight operations.
|
|
10
|
-
* @param {(item: T) => Promise<R>} worker per-item operation.
|
|
11
|
-
* @returns {Promise<R[]>} results in input order.
|
|
12
|
-
* @template T, R
|
|
13
|
-
*/
|
|
14
|
-
async function mapLimit(items, limit, worker) {
|
|
15
|
-
const results = new Array(items.length)
|
|
16
|
-
let next = 0
|
|
17
|
-
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
18
|
-
while (next < items.length) {
|
|
19
|
-
const index = next
|
|
20
|
-
next += 1
|
|
21
|
-
results[index] = await worker(items[index])
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
await Promise.all(runners)
|
|
25
|
-
return results
|
|
26
|
-
}
|
|
27
|
-
|
|
28
8
|
/**
|
|
29
9
|
* @param {string | null | undefined} directory candidate workspace directory.
|
|
30
10
|
* @returns {Promise<boolean>} whether the directory exists right now.
|
package/lib/trash-service.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
moveDirectory,
|
|
14
14
|
samePath,
|
|
15
15
|
} from './fsops.js'
|
|
16
|
+
import { mapLimit } from './concurrency.js'
|
|
16
17
|
import { reconcileIndex, rebuildIndex, memberPresence } from './inventory.js'
|
|
17
18
|
import { aggregateMetrics, memberList } from './members.js'
|
|
18
19
|
|
|
@@ -22,6 +23,9 @@ const VISIBILITY_RETRIES = 5
|
|
|
22
23
|
/** Delay between visibility probes. */
|
|
23
24
|
const VISIBILITY_DELAY_MS = 100
|
|
24
25
|
|
|
26
|
+
/** Maximum concurrent object deletions and sidecar rewrites during a purge. */
|
|
27
|
+
const PURGE_CONCURRENCY = 4
|
|
28
|
+
|
|
25
29
|
/**
|
|
26
30
|
* @param {number} milliseconds delay length.
|
|
27
31
|
* @returns {Promise<void>} resolution after the delay.
|
|
@@ -219,22 +223,46 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
/**
|
|
226
|
+
* Probe every member directory in parallel: where the source copy and the
|
|
227
|
+
* trash copy currently live.
|
|
228
|
+
*
|
|
222
229
|
* @param {object} entry index entry.
|
|
223
230
|
* @returns {Promise<{ index: number, member: object, source: boolean, trash: boolean }[]>} member positions.
|
|
224
231
|
*/
|
|
225
232
|
async function memberRows(entry) {
|
|
226
233
|
const members = membersOf(entry)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
234
|
+
return Promise.all(
|
|
235
|
+
members.map(async (member, index) => {
|
|
236
|
+
const [sourceKind, trashKind] = await Promise.all([
|
|
237
|
+
kindOf(member.originalSessionDir),
|
|
238
|
+
kindOf(memberTrashPath(entry, index)),
|
|
239
|
+
])
|
|
240
|
+
return {
|
|
241
|
+
index,
|
|
242
|
+
member,
|
|
243
|
+
source: sourceKind === 'directory',
|
|
244
|
+
trash: trashKind === 'directory',
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Probe only the original session directories of every member, in parallel.
|
|
252
|
+
* Used by recovery after the trash copies were already proven present.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} entry index entry.
|
|
255
|
+
* @returns {Promise<{ index: number, member: object, source: boolean }[]>} member source positions.
|
|
256
|
+
*/
|
|
257
|
+
async function sourceRows(entry) {
|
|
258
|
+
const members = membersOf(entry)
|
|
259
|
+
return Promise.all(
|
|
260
|
+
members.map(async (member, index) => ({
|
|
231
261
|
index,
|
|
232
262
|
member,
|
|
233
263
|
source: (await kindOf(member.originalSessionDir)) === 'directory',
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
return rows
|
|
264
|
+
})),
|
|
265
|
+
)
|
|
238
266
|
}
|
|
239
267
|
|
|
240
268
|
/**
|
|
@@ -382,14 +410,14 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
382
410
|
* @returns {Promise<{ hidden: boolean, sessionId?: string }>} whether session storage stopped reporting every member.
|
|
383
411
|
*/
|
|
384
412
|
async function waitForMembersHidden(entry) {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
member.sessionId,
|
|
388
|
-
(current) => current === undefined || current === null,
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return { hidden: true }
|
|
413
|
+
const results = await Promise.all(
|
|
414
|
+
membersOf(entry).map(async (member) => ({
|
|
415
|
+
sessionId: member.sessionId,
|
|
416
|
+
hidden: await waitForVisibility(member.sessionId, (current) => current === undefined || current === null),
|
|
417
|
+
})),
|
|
418
|
+
)
|
|
419
|
+
const failed = results.find((result) => !result.hidden)
|
|
420
|
+
return failed === undefined ? { hidden: true } : { hidden: false, sessionId: failed.sessionId }
|
|
393
421
|
}
|
|
394
422
|
|
|
395
423
|
/**
|
|
@@ -397,14 +425,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
397
425
|
* @returns {Promise<{ visible: boolean, sessionId?: string }>} whether session storage reports every member again.
|
|
398
426
|
*/
|
|
399
427
|
async function waitForMembersVisible(entry) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
member.sessionId,
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
428
|
+
const results = await Promise.all(
|
|
429
|
+
membersOf(entry).map(async (member) => ({
|
|
430
|
+
sessionId: member.sessionId,
|
|
431
|
+
visible: await waitForVisibility(
|
|
432
|
+
member.sessionId,
|
|
433
|
+
(current) => current !== undefined && current !== null && current.location !== undefined,
|
|
434
|
+
),
|
|
435
|
+
})),
|
|
436
|
+
)
|
|
437
|
+
const failed = results.find((result) => !result.visible)
|
|
438
|
+
return failed === undefined ? { visible: true } : { visible: false, sessionId: failed.sessionId }
|
|
408
439
|
}
|
|
409
440
|
|
|
410
441
|
/**
|
|
@@ -826,6 +857,99 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
826
857
|
return entry.trashedAt + config.retentionDays * 86_400_000 <= now
|
|
827
858
|
}
|
|
828
859
|
|
|
860
|
+
/**
|
|
861
|
+
* Permanently remove a batch of object directories and their index entries in
|
|
862
|
+
* a bounded number of index writes. Every entry is first committed as
|
|
863
|
+
* `purging` in one write, so a crash still settles forward on the next
|
|
864
|
+
* recovery; successes are dropped and failures annotated in one final write.
|
|
865
|
+
* The caller owns the trash lock.
|
|
866
|
+
*
|
|
867
|
+
* @param {readonly object[]} targets `trashed` entries to purge.
|
|
868
|
+
* @returns {Promise<{ purged: number, failures: { entry: object, error: unknown, phase: string }[] }>} per-entry outcome.
|
|
869
|
+
*/
|
|
870
|
+
async function purgeManyInsideLock(targets) {
|
|
871
|
+
const failures = []
|
|
872
|
+
const purging = []
|
|
873
|
+
for (const entry of targets) {
|
|
874
|
+
try {
|
|
875
|
+
await assertSafeObject(entry)
|
|
876
|
+
purging.push({ ...entry, state: 'purging', lastError: null })
|
|
877
|
+
} catch (error) {
|
|
878
|
+
failures.push({ entry, error, phase: 'validate' })
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (purging.length === 0) return { purged: 0, failures }
|
|
882
|
+
|
|
883
|
+
let marked = store.entries()
|
|
884
|
+
for (const entry of purging) marked = upsertEntry(marked, entry)
|
|
885
|
+
try {
|
|
886
|
+
await store.commit(marked)
|
|
887
|
+
} catch (error) {
|
|
888
|
+
const failure = new TrashError(
|
|
889
|
+
'INDEX_WRITE_FAILED',
|
|
890
|
+
'The session logs were not deleted because the trash index could not be updated.',
|
|
891
|
+
{ code: error instanceof TrashError ? error.code : null },
|
|
892
|
+
)
|
|
893
|
+
for (const entry of purging) failures.push({ entry, error: failure, phase: 'mark' })
|
|
894
|
+
return { purged: 0, failures }
|
|
895
|
+
}
|
|
896
|
+
await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
|
|
897
|
+
await store.writeMeta(entry).catch((error) => {
|
|
898
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
899
|
+
})
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
const deleted = await mapLimit(purging, PURGE_CONCURRENCY, async (entry) => {
|
|
903
|
+
try {
|
|
904
|
+
await store.removeObjectDir(entry.trashId)
|
|
905
|
+
return { entry, deleted: true }
|
|
906
|
+
} catch (error) {
|
|
907
|
+
return { entry, deleted: false, error }
|
|
908
|
+
}
|
|
909
|
+
})
|
|
910
|
+
const completed = []
|
|
911
|
+
const annotated = []
|
|
912
|
+
for (const result of deleted) {
|
|
913
|
+
if (result.deleted) {
|
|
914
|
+
completed.push(result.entry)
|
|
915
|
+
logger.info(`permanently deleted ${result.entry.sessionId} (${result.entry.trashId})`)
|
|
916
|
+
continue
|
|
917
|
+
}
|
|
918
|
+
annotated.push({
|
|
919
|
+
...result.entry,
|
|
920
|
+
lastError: errorText(`The session log could not be deleted: ${messageOf(result.error)}`),
|
|
921
|
+
})
|
|
922
|
+
failures.push({
|
|
923
|
+
entry: result.entry,
|
|
924
|
+
error: new TrashError('IO_ERROR', 'The session log could not be deleted.', { trashId: result.entry.trashId }),
|
|
925
|
+
phase: 'delete',
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let settled = store.entries()
|
|
930
|
+
for (const entry of completed) settled = dropEntry(settled, entry.sessionId)
|
|
931
|
+
for (const entry of annotated) settled = upsertEntry(settled, entry)
|
|
932
|
+
try {
|
|
933
|
+
await store.commit(settled)
|
|
934
|
+
} catch (error) {
|
|
935
|
+
const failure = new TrashError(
|
|
936
|
+
'INDEX_WRITE_FAILED',
|
|
937
|
+
'The session logs were deleted, but the trash index could not be updated.',
|
|
938
|
+
{ code: error instanceof TrashError ? error.code : null },
|
|
939
|
+
)
|
|
940
|
+
for (const entry of completed) failures.push({ entry, error: failure, phase: 'drop' })
|
|
941
|
+
// The deleted objects no longer match the purging entries; the next
|
|
942
|
+
// recovery drops them, so nothing is reported as successfully purged.
|
|
943
|
+
return { purged: 0, failures }
|
|
944
|
+
}
|
|
945
|
+
await mapLimit(annotated, PURGE_CONCURRENCY, async (entry) => {
|
|
946
|
+
await store.writeMeta(entry).catch((error) => {
|
|
947
|
+
logger.warn(`cannot rewrite trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
948
|
+
})
|
|
949
|
+
})
|
|
950
|
+
return { purged: completed.length, failures }
|
|
951
|
+
}
|
|
952
|
+
|
|
829
953
|
/**
|
|
830
954
|
* Permanently remove one object directory and its index entry. The caller
|
|
831
955
|
* owns the trash lock.
|
|
@@ -834,26 +958,35 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
834
958
|
* @returns {Promise<void>} resolution once the object and entry are gone.
|
|
835
959
|
*/
|
|
836
960
|
async function purgeInsideLock(entry) {
|
|
837
|
-
await
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
961
|
+
const result = await purgeManyInsideLock([entry])
|
|
962
|
+
if (result.failures.length > 0) throw result.failures[0].error
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Drop a batch of `missing` entries from the index in one write and remove
|
|
967
|
+
* their plugin-owned remnants. No session file is deleted. The caller owns
|
|
968
|
+
* the trash lock.
|
|
969
|
+
*
|
|
970
|
+
* @param {readonly object[]} entries `missing` index entries to forget.
|
|
971
|
+
* @returns {Promise<number>} how many entries were removed from the index.
|
|
972
|
+
*/
|
|
973
|
+
async function forgetManyInsideLock(entries) {
|
|
974
|
+
if (entries.length === 0) return 0
|
|
975
|
+
let next = store.entries()
|
|
976
|
+
for (const entry of entries) next = dropEntry(next, entry.sessionId)
|
|
849
977
|
try {
|
|
850
|
-
await store.commit(
|
|
978
|
+
await store.commit(next)
|
|
851
979
|
} catch (error) {
|
|
852
|
-
throw new TrashError('INDEX_WRITE_FAILED', 'The
|
|
980
|
+
throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
|
|
853
981
|
code: error instanceof TrashError ? error.code : null,
|
|
854
982
|
})
|
|
855
983
|
}
|
|
856
|
-
|
|
984
|
+
for (const entry of entries) {
|
|
985
|
+
await store.removeObjectRemnants(entry.trashId).catch((error) => {
|
|
986
|
+
logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
return entries.length
|
|
857
990
|
}
|
|
858
991
|
|
|
859
992
|
/**
|
|
@@ -864,16 +997,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
864
997
|
* @returns {Promise<{ sessionId: string, title: string }>} the removed identity.
|
|
865
998
|
*/
|
|
866
999
|
async function forgetInsideLock(entry) {
|
|
867
|
-
|
|
868
|
-
await store.commit(dropEntry(store.entries(), entry.sessionId))
|
|
869
|
-
} catch (error) {
|
|
870
|
-
throw new TrashError('INDEX_WRITE_FAILED', 'The trash index could not be updated.', {
|
|
871
|
-
code: error instanceof TrashError ? error.code : null,
|
|
872
|
-
})
|
|
873
|
-
}
|
|
874
|
-
await store.removeObjectRemnants(entry.trashId).catch((error) => {
|
|
875
|
-
logger.warn(`cannot remove the remnants of ${entry.trashId}: ${messageOf(error)}`)
|
|
876
|
-
})
|
|
1000
|
+
await forgetManyInsideLock([entry])
|
|
877
1001
|
return { sessionId: entry.sessionId, title: entry.title }
|
|
878
1002
|
}
|
|
879
1003
|
|
|
@@ -1222,12 +1346,16 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1222
1346
|
/**
|
|
1223
1347
|
* Apply the crash matrix to every index entry. The caller must hold the trash
|
|
1224
1348
|
* lock. It never deletes an unindexed object and never removes a `missing`
|
|
1225
|
-
* entry on its own.
|
|
1349
|
+
* entry on its own. An optional presence map lets the caller reuse the probe
|
|
1350
|
+
* results from the preceding reconcile instead of stat-ing every member
|
|
1351
|
+
* directory again.
|
|
1226
1352
|
*
|
|
1353
|
+
* @param {Map<string, { total: number, present: number, missing: string[] }>} [presenceByTrashId] reconcile probe results keyed by object identity.
|
|
1227
1354
|
* @returns {Promise<{ changed: number, completed: number, rolledBack: number, missing: number }>} recovery summary.
|
|
1228
1355
|
*/
|
|
1229
|
-
async function recoverInsideLock() {
|
|
1356
|
+
async function recoverInsideLock(presenceByTrashId = new Map()) {
|
|
1230
1357
|
let entries = [...store.entries()]
|
|
1358
|
+
const metaUpdates = []
|
|
1231
1359
|
let changed = 0
|
|
1232
1360
|
let completed = 0
|
|
1233
1361
|
let rolledBack = 0
|
|
@@ -1235,6 +1363,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1235
1363
|
const commit = (next) => {
|
|
1236
1364
|
if (next === undefined) return
|
|
1237
1365
|
entries = upsertEntry(entries, next)
|
|
1366
|
+
metaUpdates.push(next)
|
|
1238
1367
|
}
|
|
1239
1368
|
const drop = (sessionId) => {
|
|
1240
1369
|
entries = dropEntry(entries, sessionId)
|
|
@@ -1300,7 +1429,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1300
1429
|
|
|
1301
1430
|
if (original.state === 'trashed') {
|
|
1302
1431
|
await assertSafeObject(original)
|
|
1303
|
-
const presence = await memberPresence(store, original)
|
|
1432
|
+
const presence = presenceByTrashId.get(original.trashId) ?? (await memberPresence(store, original))
|
|
1304
1433
|
if (presence.present < presence.total) {
|
|
1305
1434
|
commit({
|
|
1306
1435
|
...original,
|
|
@@ -1314,8 +1443,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1314
1443
|
changed += 1
|
|
1315
1444
|
continue
|
|
1316
1445
|
}
|
|
1317
|
-
const
|
|
1318
|
-
if (
|
|
1446
|
+
const sources = await sourceRows(original)
|
|
1447
|
+
if (sources.some((row) => row.source)) {
|
|
1319
1448
|
commit(fail(original, 'An original session directory unexpectedly exists alongside the trash copy; both were kept.'))
|
|
1320
1449
|
changed += 1
|
|
1321
1450
|
}
|
|
@@ -1328,7 +1457,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1328
1457
|
|
|
1329
1458
|
if (changed > 0) {
|
|
1330
1459
|
await store.commit(entries)
|
|
1331
|
-
for (const entry of
|
|
1460
|
+
for (const entry of metaUpdates) {
|
|
1332
1461
|
await store.writeMeta(entry).catch((error) => {
|
|
1333
1462
|
logger.warn(`cannot refresh trash metadata for ${entry.trashId}: ${messageOf(error)}`)
|
|
1334
1463
|
})
|
|
@@ -1355,7 +1484,7 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1355
1484
|
return summary
|
|
1356
1485
|
} catch (error) {
|
|
1357
1486
|
logger.warn(`trash index reconciliation failed: ${messageOf(error)}`)
|
|
1358
|
-
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0 }
|
|
1487
|
+
return { imported: 0, markedMissing: 0, unindexed: store.unindexed, changed: 0, presence: new Map() }
|
|
1359
1488
|
}
|
|
1360
1489
|
}
|
|
1361
1490
|
|
|
@@ -1372,18 +1501,17 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1372
1501
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses cleanup.')
|
|
1373
1502
|
}
|
|
1374
1503
|
const reconcile = await reconcileInsideLock(now)
|
|
1375
|
-
const recovery = await recoverInsideLock()
|
|
1504
|
+
const recovery = await recoverInsideLock(reconcile.presence)
|
|
1376
1505
|
let purged = 0
|
|
1377
1506
|
let failed = 0
|
|
1378
1507
|
if (allowPurge) {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
logger.warn(`cannot purge the expired session ${entry.sessionId}: ${messageOf(error)}`)
|
|
1508
|
+
const expired = [...store.entries()].filter((entry) => isExpired(entry, now))
|
|
1509
|
+
if (expired.length > 0) {
|
|
1510
|
+
const result = await purgeManyInsideLock(expired)
|
|
1511
|
+
purged = result.purged
|
|
1512
|
+
failed = result.failures.length
|
|
1513
|
+
for (const failure of result.failures) {
|
|
1514
|
+
logger.warn(`cannot purge the expired session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
|
|
1387
1515
|
}
|
|
1388
1516
|
}
|
|
1389
1517
|
}
|
|
@@ -1453,34 +1581,59 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1453
1581
|
throw new TrashError('INDEX_CORRUPT', 'The trash index is unreadable, so protection mode refuses deletion.')
|
|
1454
1582
|
}
|
|
1455
1583
|
const reconcile = await reconcileInsideLock(Date.now())
|
|
1456
|
-
const recovery = await recoverInsideLock()
|
|
1457
|
-
let purged = 0
|
|
1458
|
-
let forgotten = 0
|
|
1459
|
-
let failed = 0
|
|
1584
|
+
const recovery = await recoverInsideLock(reconcile.presence)
|
|
1460
1585
|
const failures = []
|
|
1586
|
+
const purgeTargets = []
|
|
1587
|
+
const forgetTargets = []
|
|
1588
|
+
let failed = 0
|
|
1461
1589
|
for (const entry of [...store.entries()]) {
|
|
1590
|
+
if (entry.state === 'trashed') {
|
|
1591
|
+
purgeTargets.push(entry)
|
|
1592
|
+
continue
|
|
1593
|
+
}
|
|
1594
|
+
if (entry.state === 'missing') {
|
|
1595
|
+
forgetTargets.push(entry)
|
|
1596
|
+
continue
|
|
1597
|
+
}
|
|
1462
1598
|
const title = typeof entry.title === 'string' ? entry.title : ''
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1599
|
+
const message = `The trash entry is in state "${entry.state}" and cannot be cleaned up.`
|
|
1600
|
+
failed += 1
|
|
1601
|
+
failures.push({ sessionId: entry.sessionId, title, message })
|
|
1602
|
+
logger.warn(`cannot purge the session ${entry.sessionId}: ${message}`)
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
let purged = 0
|
|
1606
|
+
if (purgeTargets.length > 0) {
|
|
1607
|
+
const result = await purgeManyInsideLock(purgeTargets)
|
|
1608
|
+
purged = result.purged
|
|
1609
|
+
for (const failure of result.failures) {
|
|
1474
1610
|
failed += 1
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1611
|
+
failures.push({
|
|
1612
|
+
sessionId: failure.entry.sessionId,
|
|
1613
|
+
title: typeof failure.entry.title === 'string' ? failure.entry.title : '',
|
|
1614
|
+
message: messageOf(failure.error),
|
|
1615
|
+
})
|
|
1616
|
+
logger.warn(`cannot purge the trashed session ${failure.entry.sessionId}: ${messageOf(failure.error)}`)
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
let forgotten = 0
|
|
1621
|
+
if (forgetTargets.length > 0) {
|
|
1622
|
+
try {
|
|
1623
|
+
forgotten = await forgetManyInsideLock(forgetTargets)
|
|
1478
1624
|
} catch (error) {
|
|
1479
|
-
failed +=
|
|
1480
|
-
|
|
1481
|
-
|
|
1625
|
+
failed += forgetTargets.length
|
|
1626
|
+
for (const entry of forgetTargets) {
|
|
1627
|
+
failures.push({
|
|
1628
|
+
sessionId: entry.sessionId,
|
|
1629
|
+
title: typeof entry.title === 'string' ? entry.title : '',
|
|
1630
|
+
message: messageOf(error),
|
|
1631
|
+
})
|
|
1632
|
+
logger.warn(`cannot forget the missing session ${entry.sessionId}: ${messageOf(error)}`)
|
|
1633
|
+
}
|
|
1482
1634
|
}
|
|
1483
1635
|
}
|
|
1636
|
+
|
|
1484
1637
|
logger.info(`bulk purge finished: purged ${purged}, forgotten ${forgotten}, failed ${failed}`)
|
|
1485
1638
|
return {
|
|
1486
1639
|
purged,
|
|
@@ -1569,8 +1722,8 @@ export function createTrashService({ ctx, store, eligibility, config, logger, pa
|
|
|
1569
1722
|
try {
|
|
1570
1723
|
summary = await store.withLock(async () => {
|
|
1571
1724
|
if (store.isProtected()) return { changed: 0, completed: 0, rolledBack: 0, missing: 0 }
|
|
1572
|
-
await reconcileInsideLock(Date.now())
|
|
1573
|
-
return await recoverInsideLock()
|
|
1725
|
+
const reconcile = await reconcileInsideLock(Date.now())
|
|
1726
|
+
return await recoverInsideLock(reconcile.presence)
|
|
1574
1727
|
})
|
|
1575
1728
|
} catch (error) {
|
|
1576
1729
|
if (error instanceof TrashError && error.code === 'LOCKED') {
|