Skip to content

Commit 913f4ec

Browse files
committed
Added an experimental recovery tool to fix an inconsistent recording database.
This new command scans the actual recording data found on disk and checks for missing or inconsistent database entries. It can be used to repair or rebuild the recordings database after a crash or when your database backup is missing a few recordings. It may also prove helpful if you need to migrate a large number of recordings from one tenant to the other.
1 parent cd42152 commit 913f4ec

3 files changed

Lines changed: 465 additions & 100 deletions

File tree

bbblb/cli/recording.py

Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# Copyright (C) 2025, 2026 Marcel Hellkamp
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33

4+
5+
import sys
6+
47
import click
58
import sqlalchemy.orm
69

710
from bbblb import model
811

912
from bbblb.services import ServiceRegistry
1013
from bbblb.services.db import DBContext
11-
from bbblb.services.recording import RecordingManager
14+
from bbblb.services.recording import RecordingManager, ConsistencyChecker, FixCategory
1215

1316
from . import main, async_command
1417

@@ -137,40 +140,75 @@ async def reader(file):
137140

138141
@recording.command()
139142
@click.option(
140-
"--dry-run", "-n", help="Do not actually remove any recordings.", is_flag=True
143+
"--prefix",
144+
help="Only scan recording with IDs starting with this prefix.",
145+
default="",
146+
)
147+
@click.option(
148+
"--fix-orphans",
149+
help="Remove recordings or formats that do not exist on disk.",
150+
is_flag=True,
151+
)
152+
@click.option(
153+
"--fix-missing",
154+
help="Import missing recordings or formats found on disk.",
155+
is_flag=True,
156+
)
157+
@click.option(
158+
"--fix-state",
159+
help="Fix the published/unpublished state of recordings to match the on-disk state.",
160+
is_flag=True,
161+
)
162+
@click.option(
163+
"--fix-tenant",
164+
help="Fix the recording owner to match their on-disk storage path, which contains the tenant name.",
165+
is_flag=True,
166+
)
167+
@click.option(
168+
"--fix-metadata",
169+
help="(NOT IMPLEMENTED) Fix the recording metadata from the most recend on-disk backup.",
170+
is_flag=True,
171+
)
172+
@click.option(
173+
"--fix-all", help="Fix everything that can be fixed automatically.", is_flag=True
141174
)
142175
@async_command()
143-
async def remove_orphans(obj: ServiceRegistry, dry_run: bool):
144-
"""Remove recording DB entries that do not exist on disk."""
145-
db = await obj.use(DBContext)
176+
async def check_database(
177+
obj: ServiceRegistry,
178+
prefix: str,
179+
fix_orphans: bool,
180+
fix_missing: bool,
181+
fix_state: bool,
182+
fix_tenant: bool,
183+
fix_metadata: bool,
184+
fix_all: bool,
185+
):
186+
"""(experimental) Report and optionally fix issues with the recording database.
187+
188+
This command scans the actual recording data found on disk and
189+
checks for missing or inconsistent database entries. It can be used
190+
to repair or rebuild the recordings database after a crash or when
191+
your database backup is missing a few recordings.
192+
193+
Warning, this command may run for a while and consume a lot of memory
194+
if you have many recordings. It is also NOT safe to run this command
195+
while BBBLB running and processing new recordings. Stop all BBBLB
196+
API and worker processes before running this command with enabled
197+
fixes. Make backups first.
198+
199+
The command
200+
"""
201+
autofix: set[FixCategory] = set()
202+
if fix_orphans or fix_all:
203+
autofix.add(FixCategory.ORPHAN)
204+
if fix_missing or fix_all:
205+
autofix.add(FixCategory.MISSING)
206+
if fix_state or fix_all:
207+
autofix.add(FixCategory.PUB_STATE)
208+
if fix_tenant or fix_all:
209+
autofix.add(FixCategory.TENANT)
210+
146211
importer = await obj.use(RecordingManager)
147-
async with db.session() as session, session.begin():
148-
stmt = model.Recording.select().options(
149-
sqlalchemy.orm.joinedload(model.Recording.tenant),
150-
sqlalchemy.orm.selectinload(model.Recording.formats),
151-
)
152-
records = await session.execute(stmt)
153-
for record in records.scalars():
154-
populated = False
155-
for format in record.formats:
156-
sdir = importer.get_storage_dir(
157-
record.tenant.name,
158-
record.record_id,
159-
format.format,
160-
)
161-
if sdir.exists():
162-
populated = True
163-
continue
164-
click.echo(
165-
f"Deleting orphan format: {record.tenant.name}/{record.record_id}/{format.format}"
166-
)
167-
await session.delete(format)
168-
if not populated:
169-
click.echo(
170-
f"Deleting record without formats: {record.tenant.name}/{record.record_id}"
171-
)
172-
await session.delete(record)
173-
174-
if dry_run:
175-
click.echo("Rolling back changes (dry run)")
176-
await session.rollback()
212+
recovery = ConsistencyChecker(importer, autofix)
213+
await recovery.scan(prefix=prefix)
214+
sys.exit(1 if recovery.unfixed_issues else 0)

0 commit comments

Comments
 (0)