leblob 0.4.0.dev1__py3-none-any.whl
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.
- leblob/__init__.py +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
leblob/maintenance.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Background work, and the consistency check behind `leblob fsck`.
|
|
2
|
+
|
|
3
|
+
Every worker runs the maintenance loop. There is no leader, because each step
|
|
4
|
+
is safe to run concurrently: unlinking a blob twice is harmless.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from leblob import meta, metrics
|
|
18
|
+
from leblob.blobstore import (
|
|
19
|
+
TMP_MAX_AGE_SECONDS,
|
|
20
|
+
CorruptSourceError,
|
|
21
|
+
TargetVolumeError,
|
|
22
|
+
VolumeError,
|
|
23
|
+
copy_blob,
|
|
24
|
+
open_store,
|
|
25
|
+
open_volume,
|
|
26
|
+
)
|
|
27
|
+
from leblob.meta import GC_GRACE_MS
|
|
28
|
+
from leblob.model import BlobFile
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from leblob.blobstore import BlobStore, Volume
|
|
35
|
+
from leblob.meta import Database
|
|
36
|
+
from leblob.model import (
|
|
37
|
+
Blob,
|
|
38
|
+
KnownBlob,
|
|
39
|
+
LifecycleReport,
|
|
40
|
+
RepairWork,
|
|
41
|
+
VolumeRecord,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
_INTERVAL_SECONDS = 60
|
|
47
|
+
_NO_VOLUMES_RETRY_SECONDS = 2
|
|
48
|
+
_BATCH_SIZE = 1000
|
|
49
|
+
_REPAIR_POLL_SECONDS = 5
|
|
50
|
+
_REPAIR_BATCH = 100
|
|
51
|
+
_REPAIR_LEASE_MS = 600_000
|
|
52
|
+
_SCRUB_START_SECONDS = 60
|
|
53
|
+
_SCRUB_BATCH = 256
|
|
54
|
+
# ponytail: one rate for any disks. Make it a setting when a store needs another.
|
|
55
|
+
_SCRUB_BYTES_PER_SECOND = 32 * 1024 * 1024
|
|
56
|
+
_LONGEST_PAUSE_MS = 60_000
|
|
57
|
+
_MILLISECONDS = 1000
|
|
58
|
+
_COMPLETED_UPLOAD_MEMORY_MS = meta.DAY_MS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def run_forever(db: Database, blobs: BlobStore, data_dir: Path) -> None:
|
|
62
|
+
"""Run maintenance periodically, surviving failures of any one round."""
|
|
63
|
+
while True:
|
|
64
|
+
if blobs.volumes:
|
|
65
|
+
# Jitter, so that workers started together do not all run at once.
|
|
66
|
+
await asyncio.sleep(
|
|
67
|
+
_INTERVAL_SECONDS + secrets.randbelow(_INTERVAL_SECONDS)
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
await asyncio.sleep(_NO_VOLUMES_RETRY_SECONDS)
|
|
71
|
+
try:
|
|
72
|
+
await follow_volumes(db, blobs, data_dir)
|
|
73
|
+
now = meta.now_ms()
|
|
74
|
+
await collect(db, blobs, now)
|
|
75
|
+
await meta.forget_completed_uploads(db, now - _COMPLETED_UPLOAD_MEMORY_MS)
|
|
76
|
+
await asyncio.to_thread(blobs.sweep_tmp, time.time())
|
|
77
|
+
except Exception:
|
|
78
|
+
logger.exception("Maintenance round failed; it will run again later.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def follow_volumes(db: Database, blobs: BlobStore, data_dir: Path) -> None:
|
|
82
|
+
"""Pick up changes made by commands, and disks that have come or gone."""
|
|
83
|
+
records, copies = await meta.current_volumes(db)
|
|
84
|
+
current = await asyncio.to_thread(
|
|
85
|
+
open_store, data_dir, records, copies, allow_degraded=True
|
|
86
|
+
)
|
|
87
|
+
blobs.follow(current)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def current_gauges(db: Database, data_dir: Path) -> list[metrics.Sample]:
|
|
91
|
+
"""What is true right now of the store, whichever worker is asked."""
|
|
92
|
+
records, copies = await meta.current_volumes(db)
|
|
93
|
+
return [
|
|
94
|
+
(metrics.COPIES, (), copies),
|
|
95
|
+
(metrics.REPAIRS_QUEUED, (), await meta.repairs_queued(db)),
|
|
96
|
+
(
|
|
97
|
+
metrics.BLOBS_AWAITING_COLLECTION,
|
|
98
|
+
(),
|
|
99
|
+
await meta.blobs_awaiting_collection(db),
|
|
100
|
+
),
|
|
101
|
+
*await asyncio.to_thread(_volume_gauges, data_dir, records),
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _volume_gauges(
|
|
106
|
+
data_dir: Path, records: Iterable[VolumeRecord]
|
|
107
|
+
) -> list[metrics.Sample]:
|
|
108
|
+
gauges: list[metrics.Sample] = []
|
|
109
|
+
for record in records:
|
|
110
|
+
labels = (("volume", str(record.volume_id)),)
|
|
111
|
+
gauges.append((metrics.VOLUME_DRAINING, labels, int(record.draining)))
|
|
112
|
+
try:
|
|
113
|
+
free = open_volume(data_dir, record).free_bytes()
|
|
114
|
+
except (VolumeError, OSError):
|
|
115
|
+
gauges.append((metrics.VOLUME_UP, labels, 0))
|
|
116
|
+
else:
|
|
117
|
+
gauges += [
|
|
118
|
+
(metrics.VOLUME_UP, labels, 1),
|
|
119
|
+
(metrics.VOLUME_FREE_BYTES, labels, free),
|
|
120
|
+
]
|
|
121
|
+
return gauges
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def run_repairs(db: Database, blobs: BlobStore) -> None:
|
|
125
|
+
"""Restore copies as they are needed, surviving failures of any one round."""
|
|
126
|
+
while True:
|
|
127
|
+
await asyncio.sleep(_REPAIR_POLL_SECONDS)
|
|
128
|
+
try:
|
|
129
|
+
while await meta.repairs_due(db, meta.now_ms()):
|
|
130
|
+
await repair_round(db, blobs, meta.now_ms())
|
|
131
|
+
except Exception:
|
|
132
|
+
logger.exception("Repair round failed; it will run again later.")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def repair_round(db: Database, blobs: BlobStore, now: int) -> int:
|
|
136
|
+
"""Work through a batch of the queue. Returns how many blobs are done with."""
|
|
137
|
+
done = 0
|
|
138
|
+
for work in await meta.claim_repairs(db, now, _REPAIR_BATCH, _REPAIR_LEASE_MS):
|
|
139
|
+
if await _repair(db, blobs, work):
|
|
140
|
+
done += 1
|
|
141
|
+
return done
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _repair(db: Database, blobs: BlobStore, work: RepairWork) -> bool:
|
|
145
|
+
"""Bring a blob up to its copies, and off any draining volume once it is safe.
|
|
146
|
+
|
|
147
|
+
The queue keeps a blob until this succeeds, so giving up here only delays it.
|
|
148
|
+
"""
|
|
149
|
+
blob = work.blob
|
|
150
|
+
active = [key for key in blob.volume_ids if key not in work.draining]
|
|
151
|
+
needed = work.copies - len(active)
|
|
152
|
+
if needed > 0:
|
|
153
|
+
added, placed = await _add_copies(db, blobs, work, needed)
|
|
154
|
+
if added < needed:
|
|
155
|
+
return False
|
|
156
|
+
if not active and work.draining.intersection(blob.volume_ids):
|
|
157
|
+
# A read that started before this knows only the draining copy, so that
|
|
158
|
+
# stays for the grace period, as any deleted blob does.
|
|
159
|
+
await meta.postpone_repair(db, blob.blob_id, meta.now_ms() + GC_GRACE_MS)
|
|
160
|
+
return False
|
|
161
|
+
active += placed
|
|
162
|
+
# Copies on a disk that is away don't count: this one may be all that can be read.
|
|
163
|
+
if any(key in blobs.volumes for key in active):
|
|
164
|
+
for volume_id in work.draining.intersection(blob.volume_ids):
|
|
165
|
+
await _remove_drained_copy(db, blobs, blob.blob_id, volume_id)
|
|
166
|
+
return await meta.finish_repair(db, blob.blob_id)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _add_copies(
|
|
170
|
+
db: Database, blobs: BlobStore, work: RepairWork, needed: int
|
|
171
|
+
) -> tuple[int, list[int]]:
|
|
172
|
+
"""Make new copies, and return how many it managed, and on which volumes."""
|
|
173
|
+
blob = work.blob
|
|
174
|
+
placed: list[int] = []
|
|
175
|
+
for target in blobs.placement_order(exclude=(*blob.volume_ids, *work.draining)):
|
|
176
|
+
if len(placed) == needed:
|
|
177
|
+
break
|
|
178
|
+
try:
|
|
179
|
+
await copy_blob(blobs, blob, target)
|
|
180
|
+
except TargetVolumeError as error:
|
|
181
|
+
blobs.report_failure(target.volume_id, OSError(str(error)))
|
|
182
|
+
continue
|
|
183
|
+
except CorruptSourceError:
|
|
184
|
+
logger.exception(
|
|
185
|
+
"Can't copy blob %s yet; scrub finds the bad one", blob.blob_id
|
|
186
|
+
)
|
|
187
|
+
break
|
|
188
|
+
except OSError as error:
|
|
189
|
+
logger.warning("Can't read blob %s to copy it: %s", blob.blob_id, error)
|
|
190
|
+
break
|
|
191
|
+
if not await meta.add_location(db, blob.blob_id, target.volume_id):
|
|
192
|
+
target.delete(blob.blob_id) # Collected while it was being copied.
|
|
193
|
+
return needed, []
|
|
194
|
+
placed.append(target.volume_id)
|
|
195
|
+
metrics.counters.add(metrics.COPIES_REPAIRED)
|
|
196
|
+
return len(placed), placed
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def _remove_drained_copy(
|
|
200
|
+
db: Database, blobs: BlobStore, blob_id: str, volume_id: int
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Remove the row and then the file, so that a crash leaves at worst an orphan.
|
|
203
|
+
|
|
204
|
+
Unlike with a bad copy, no repair can be writing here: this volume takes no blobs.
|
|
205
|
+
"""
|
|
206
|
+
volume = blobs.volumes.get(volume_id)
|
|
207
|
+
if volume is None:
|
|
208
|
+
return # The disk is away. `volume forget --disk-lost` deals with it.
|
|
209
|
+
await meta.remove_location(db, blob_id, volume_id)
|
|
210
|
+
try:
|
|
211
|
+
await asyncio.to_thread(volume.delete, blob_id)
|
|
212
|
+
except OSError as error:
|
|
213
|
+
blobs.report_failure(volume_id, error)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def run_scrub(db: Database, blobs: BlobStore) -> None:
|
|
217
|
+
"""Check every copy, slowly and for ever, surviving failures of any one round."""
|
|
218
|
+
await asyncio.sleep(_SCRUB_START_SECONDS + secrets.randbelow(_INTERVAL_SECONDS))
|
|
219
|
+
await _keep_doing(
|
|
220
|
+
"Scrub",
|
|
221
|
+
partial(meta.scrub_due_at, db),
|
|
222
|
+
lambda now: scrub_round(db, blobs, now),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def _keep_doing(
|
|
227
|
+
what: str,
|
|
228
|
+
due_at: Callable[[], Awaitable[int]],
|
|
229
|
+
work: Callable[[int], Awaitable[object]],
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Do work whenever it is due, for ever, surviving failures of any one round."""
|
|
232
|
+
while True:
|
|
233
|
+
try:
|
|
234
|
+
pause = await _do_if_due(due_at, work)
|
|
235
|
+
except Exception:
|
|
236
|
+
logger.exception("%s round failed; it will run again later.", what)
|
|
237
|
+
pause = _INTERVAL_SECONDS
|
|
238
|
+
await asyncio.sleep(pause)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _do_if_due(
|
|
242
|
+
due_at: Callable[[], Awaitable[int]], work: Callable[[int], Awaitable[object]]
|
|
243
|
+
) -> float:
|
|
244
|
+
"""Work if it is time. Returns the seconds to wait before looking again."""
|
|
245
|
+
now = meta.now_ms()
|
|
246
|
+
due = await due_at()
|
|
247
|
+
if due > now:
|
|
248
|
+
return min(due - now, _LONGEST_PAUSE_MS) / _MILLISECONDS
|
|
249
|
+
await work(now)
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def run_lifecycle(db: Database, day_ms: int) -> None:
|
|
254
|
+
"""Apply lifecycle rules once a day, surviving failures of any one round."""
|
|
255
|
+
await _keep_doing(
|
|
256
|
+
"Lifecycle",
|
|
257
|
+
partial(meta.lifecycle_due_at, db),
|
|
258
|
+
lambda now: lifecycle_round(db, now, day_ms),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def lifecycle_round(db: Database, now: int, day_ms: int) -> LifecycleReport:
|
|
263
|
+
report = await meta.expire_batch(db, now, day_ms)
|
|
264
|
+
metrics.counters.add(metrics.LIFECYCLE_OBJECTS_EXPIRED, report.expired)
|
|
265
|
+
metrics.counters.add(metrics.LIFECYCLE_VERSIONS_ERASED, report.erased)
|
|
266
|
+
metrics.counters.add(metrics.LIFECYCLE_UPLOADS_ABORTED, report.aborted)
|
|
267
|
+
return report
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def scrub_round(db: Database, blobs: BlobStore, now: int) -> ScrubReport:
|
|
271
|
+
batch = await meta.claim_scrub_batch(db, now, _SCRUB_BATCH, _SCRUB_BYTES_PER_SECOND)
|
|
272
|
+
return await _scrub_blobs(db, blobs, batch)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def scrub_everything(db: Database, blobs: BlobStore) -> ScrubReport:
|
|
276
|
+
"""Check every copy now, at full speed, for `leblob scrub`."""
|
|
277
|
+
report = ScrubReport()
|
|
278
|
+
after = ""
|
|
279
|
+
while batch := await meta.blobs_to_scrub(db, after, _SCRUB_BATCH):
|
|
280
|
+
report = report.plus(await _scrub_blobs(db, blobs, batch))
|
|
281
|
+
after = batch[-1].blob_id
|
|
282
|
+
return report
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _scrub_blobs(
|
|
286
|
+
db: Database, blobs: BlobStore, batch: Iterable[Blob]
|
|
287
|
+
) -> ScrubReport:
|
|
288
|
+
report = ScrubReport()
|
|
289
|
+
for blob in batch:
|
|
290
|
+
for volume_id in blob.volume_ids:
|
|
291
|
+
volume = blobs.volumes.get(volume_id)
|
|
292
|
+
if volume is None:
|
|
293
|
+
continue
|
|
294
|
+
report = report.plus(ScrubReport(checked=1))
|
|
295
|
+
metrics.counters.add(metrics.COPIES_SCRUBBED)
|
|
296
|
+
if not await _copy_is_intact(volume, blob):
|
|
297
|
+
report = report.plus(await _drop_bad_copy(db, blobs, blob, volume))
|
|
298
|
+
return report
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def _copy_is_intact(volume: Volume, blob: Blob) -> bool:
|
|
302
|
+
try:
|
|
303
|
+
return await asyncio.to_thread(volume.copy_is_intact, blob)
|
|
304
|
+
except OSError:
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _drop_bad_copy(
|
|
309
|
+
db: Database, blobs: BlobStore, blob: Blob, volume: Volume
|
|
310
|
+
) -> ScrubReport:
|
|
311
|
+
"""Remove a bad copy, so that repair replaces it, unless it is all there is.
|
|
312
|
+
|
|
313
|
+
The file goes first, as with the collector: while the row remains repair leaves
|
|
314
|
+
this volume alone, so it can't write a new copy that this would then delete. It
|
|
315
|
+
also leaves everything as it was on a dead disk, which only a person should write
|
|
316
|
+
off. A crash between the two leaves a row with no file, which `fsck` reports
|
|
317
|
+
and the next scrub clears.
|
|
318
|
+
"""
|
|
319
|
+
blob_id = blob.blob_id
|
|
320
|
+
others = await meta.other_copies(db, blob_id, volume.volume_id)
|
|
321
|
+
if others is None:
|
|
322
|
+
return ScrubReport() # Collected meanwhile: not damage.
|
|
323
|
+
if not volume.identifies_itself():
|
|
324
|
+
blobs.report_failure(volume.volume_id, OSError(f"{volume.root} isn't mounted"))
|
|
325
|
+
return ScrubReport()
|
|
326
|
+
if others == 0:
|
|
327
|
+
logger.error("The only copy of blob %s is bad, on %s", blob_id, volume.root)
|
|
328
|
+
metrics.counters.add(metrics.ONLY_COPIES_BAD)
|
|
329
|
+
return ScrubReport(unrecoverable=(blob_id,))
|
|
330
|
+
try:
|
|
331
|
+
await asyncio.to_thread(volume.delete, blob_id)
|
|
332
|
+
except OSError as error:
|
|
333
|
+
blobs.report_failure(volume.volume_id, error)
|
|
334
|
+
return ScrubReport()
|
|
335
|
+
await meta.remove_location(db, blob_id, volume.volume_id)
|
|
336
|
+
logger.warning("Removed a bad copy of blob %s from %s", blob_id, volume.root)
|
|
337
|
+
metrics.counters.add(metrics.BAD_COPIES_REMOVED)
|
|
338
|
+
return ScrubReport(removed=1)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def collect(db: Database, blobs: BlobStore, now: int) -> int:
|
|
342
|
+
"""Delete blobs whose grace period is over. Returns how many were deleted."""
|
|
343
|
+
collected = 0
|
|
344
|
+
while True:
|
|
345
|
+
due = await meta.blobs_due_for_collection(db, now, _BATCH_SIZE)
|
|
346
|
+
if not due:
|
|
347
|
+
return collected
|
|
348
|
+
# Files go first: a crash here leaves rows to retry, never lost files.
|
|
349
|
+
await asyncio.to_thread(_delete_all, blobs, due)
|
|
350
|
+
await meta.forget_blobs(db, list(due))
|
|
351
|
+
metrics.counters.add(metrics.BLOBS_COLLECTED, len(due))
|
|
352
|
+
collected += len(due)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _delete_all(blobs: BlobStore, due: Mapping[str, Iterable[int]]) -> None:
|
|
356
|
+
for blob_id, volume_ids in due.items():
|
|
357
|
+
blobs.delete(blob_id, volume_ids)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass(frozen=True, slots=True)
|
|
361
|
+
class ScrubReport:
|
|
362
|
+
"""What checking copies found."""
|
|
363
|
+
|
|
364
|
+
checked: int = 0
|
|
365
|
+
removed: int = 0
|
|
366
|
+
unrecoverable: tuple[str, ...] = ()
|
|
367
|
+
|
|
368
|
+
def plus(self, other: ScrubReport) -> ScrubReport:
|
|
369
|
+
return ScrubReport(
|
|
370
|
+
self.checked + other.checked,
|
|
371
|
+
self.removed + other.removed,
|
|
372
|
+
self.unrecoverable + other.unrecoverable,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@dataclass(frozen=True, slots=True)
|
|
377
|
+
class FsckReport:
|
|
378
|
+
"""How the metadata and the disk disagree."""
|
|
379
|
+
|
|
380
|
+
orphans: tuple[BlobFile, ...]
|
|
381
|
+
young_orphans: tuple[BlobFile, ...]
|
|
382
|
+
missing: tuple[BlobFile, ...]
|
|
383
|
+
wrong_size: tuple[BlobFile, ...]
|
|
384
|
+
under_replicated: tuple[str, ...] = ()
|
|
385
|
+
lost: tuple[str, ...] = ()
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def is_clean(self) -> bool:
|
|
389
|
+
return not (self.orphans or self.missing or self.wrong_size or self.lost)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def fsck(db: Database, blobs: BlobStore, now: float) -> FsckReport:
|
|
393
|
+
"""Compare the blobs and their locations with every volume.
|
|
394
|
+
|
|
395
|
+
A recent file with no row may be a write about to commit, so it is reported
|
|
396
|
+
apart from older orphans, and `fsck --delete-orphans` leaves it alone.
|
|
397
|
+
|
|
398
|
+
A row with no file is damage, unless the blob is queued for collection: the
|
|
399
|
+
collector deletes the file first, and a crash or this very check can land before
|
|
400
|
+
it forgets the row. The grace period means that a blob it deletes during the scan
|
|
401
|
+
was already queued when the rows were read.
|
|
402
|
+
"""
|
|
403
|
+
known = await meta.all_blobs(db)
|
|
404
|
+
return await asyncio.to_thread(_compare, known, blobs, now)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _compare(
|
|
408
|
+
known: Mapping[str, KnownBlob], blobs: BlobStore, now: float
|
|
409
|
+
) -> FsckReport:
|
|
410
|
+
orphans: list[BlobFile] = []
|
|
411
|
+
young_orphans: list[BlobFile] = []
|
|
412
|
+
wrong_size: list[BlobFile] = []
|
|
413
|
+
present: set[tuple[int, str]] = set()
|
|
414
|
+
for volume_id, volume in blobs.volumes.items():
|
|
415
|
+
for blob_id, size, modified_at in volume.scan():
|
|
416
|
+
blob = known.get(blob_id)
|
|
417
|
+
found = BlobFile(volume_id, blob_id)
|
|
418
|
+
if blob is None or volume_id not in blob.volume_ids:
|
|
419
|
+
old = now - modified_at > TMP_MAX_AGE_SECONDS
|
|
420
|
+
(orphans if old else young_orphans).append(found)
|
|
421
|
+
elif blob.size != size:
|
|
422
|
+
wrong_size.append(found)
|
|
423
|
+
present.add((volume_id, blob_id))
|
|
424
|
+
return FsckReport(
|
|
425
|
+
tuple(orphans),
|
|
426
|
+
tuple(young_orphans),
|
|
427
|
+
tuple(
|
|
428
|
+
BlobFile(volume_id, blob_id)
|
|
429
|
+
for blob_id, blob in known.items()
|
|
430
|
+
for volume_id in blob.volume_ids
|
|
431
|
+
if (volume_id, blob_id) not in present and not blob.queued_for_collection
|
|
432
|
+
),
|
|
433
|
+
tuple(wrong_size),
|
|
434
|
+
tuple(
|
|
435
|
+
blob_id
|
|
436
|
+
for blob_id, blob in known.items()
|
|
437
|
+
if len(blob.volume_ids) < blobs.copies and not blob.queued_for_collection
|
|
438
|
+
),
|
|
439
|
+
tuple(
|
|
440
|
+
blob_id
|
|
441
|
+
for blob_id, blob in known.items()
|
|
442
|
+
if not blob.volume_ids and not blob.queued_for_collection
|
|
443
|
+
),
|
|
444
|
+
)
|