Files
devplacepy/devplacepy/database/pagination.py
T
retoorandClaude Sonnet 5 7880bf4b31 Fix container sync races that leaked orphan blobs; add a system-prune CLI command
sync_workspace (user-triggered) and the reconciler's sync_bidirectional_sync
could run concurrently for the same project, and store_upload's read-then-
write on a changed path meant two racing imports each wrote their own blob
while only one ever got referenced - the loser leaked forever. Combined with
no build-artifact exclusion, an actively-compiling workspace hit this
constantly and leaked 5.9M orphan blobs (~96GB) in production before it was
caught.

Closes it at the root: api._sync_dir_bidirectional_locked serializes both
call sites per-project (non-blocking - a project already mid-sync is simply
skipped until the next tick), and IMPORT_SKIP_NAMES/IMPORT_SKIP_EXTENSIONS
keep build output (build/, dist/, *.o, *.pyc, ...) out of the walk entirely.

Recovering what already leaked is a separate concern: a new CLI subcommand
(plus matching make targets) sweeps soft-deleted attachment/project-file
blobs and any blob with zero DB reference at all, plus orphaned container
workspace directories. run_maintenance_cleanup.sh wraps the existing
prune/clear commands for routine disk upkeep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BWJy6PrMMt5hwWxQwia2rd
2026-09-08 03:43:49 +02:00

112 lines
2.8 KiB
Python

# retoor <retoor@molodetz.nl>
from devplacepy.cache import TTLCache
from .core import db, get_table
from .relations import get_blocked_uids
PAGE_SIZE = 25
_user_post_count_cache = TTLCache(ttl=15, max_size=2000)
def paginate(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
viewer_uid=None,
limit=PAGE_SIZE,
**filters,
):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if table.has_column("deleted_at") and "deleted_at" not in filters:
clauses.append(table.table.columns.deleted_at.is_(None))
if viewer_uid and table.has_column("user_uid"):
blocked = get_blocked_uids(viewer_uid)
if blocked:
clauses.append(table.table.columns.user_uid.notin_(blocked))
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=limit + 1))
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def interleave_by_author(rows, uid_key="user_uid"):
remaining = list(rows)
spread = []
last_owner = object()
while remaining:
pick = next(
(
index
for index, row in enumerate(remaining)
if row.get(uid_key) != last_owner
),
0,
)
row = remaining.pop(pick)
spread.append(row)
last_owner = row.get(uid_key)
return spread
def paginate_diverse(
table,
*clauses,
before=None,
order=None,
cursor_field="created_at",
uid_key="user_uid",
viewer_uid=None,
limit=PAGE_SIZE,
**filters,
):
rows, next_cursor = paginate(
table,
*clauses,
before=before,
order=order,
cursor_field=cursor_field,
viewer_uid=viewer_uid,
limit=limit,
**filters,
)
return interleave_by_author(rows, uid_key=uid_key), next_cursor
def clear_user_post_count(user_uid: str) -> None:
_user_post_count_cache.pop(user_uid)
def get_user_post_count(user_uid: str) -> int:
cached = _user_post_count_cache.get(user_uid)
if cached is not None:
return cached
if "posts" not in db.tables:
return 0
count = get_table("posts").count(user_uid=user_uid, deleted_at=None)
_user_post_count_cache.set(user_uid, count)
return count
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))
return {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_prev": page > 1,
"has_next": page < total_pages,
"prev_page": page - 1,
"next_page": page + 1,
}