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/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
+ )