Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
114 commits
Select commit Hold shift + click to select a range
c0fcd1a
fix(database): configure sqlite for concurrent writers (WAL, busy tim…
synap5e Sep 15, 2026
c75c5ea
fix(assets): route ingest write paths through immediate write transac…
synap5e Sep 15, 2026
2bf7022
test(assets): cover ingest stale fallback
synap5e Sep 15, 2026
0c9fecb
test(assets): cover real divergence detection in cached registration …
synap5e Sep 15, 2026
f8bb1bd
fix(assets): make scanner and seeder writes immediate, retry-safe, an…
synap5e Sep 15, 2026
ddf1cf8
test(assets): add barrier lock-hold coverage for ingest preflight res…
synap5e Sep 15, 2026
bdb3205
fix(assets): route api and lifecycle write paths through immediate wr…
synap5e Sep 15, 2026
067a685
test(assets): guard the write-session convention
synap5e Sep 15, 2026
05c84e7
test(assets): deterministic write-contention regression coverage
synap5e Sep 15, 2026
38b2286
docs(assets): document the sqlite concurrency configuration and write…
synap5e Sep 16, 2026
d9d8f6f
test(database): close lock-holder resources
synap5e Sep 16, 2026
54556cb
test(assets): make barrier cleanup deterministic
synap5e Sep 16, 2026
5fcebe0
ci: expose unit test failures
synap5e Sep 16, 2026
b72c1c6
fix(database): enforce write timeout before begin
synap5e Sep 16, 2026
7cf1a6b
fix(assets): support cross-device upload moves
synap5e Sep 16, 2026
622f579
ci: restore standard pytest output
synap5e Sep 16, 2026
70ab3bd
fix(database): configure writer timeout before transaction begin
synap5e Sep 16, 2026
632fe84
fix(database): poll sqlite write locks within deadline
synap5e Sep 16, 2026
2140d02
test(assets): hoist imports and rename tests to describe their subject
synap5e Sep 16, 2026
b78c881
fix(database): bound sqlite writer lock waits
synap5e Sep 16, 2026
6090a9e
fix(assets): publish cross-device uploads atomically
synap5e Sep 16, 2026
33e803d
docs(assets): clarify sqlite writer timeout behavior
synap5e Sep 16, 2026
bd39104
fix(database): preserve pending lock error when rollback fails
synap5e Sep 16, 2026
3e2c748
fix(assets): use a unique sibling temp for cross-device publishes
synap5e Sep 16, 2026
5e81862
fix(database): classify sqlite lock errors by result code
synap5e Sep 16, 2026
6fb355e
feat(assets): add job id to asset events
synap5e Sep 16, 2026
10998e6
feat(assets): include job id in registration failure events
synap5e Sep 16, 2026
40f59b1
test(assets): assert job id survives contended registration
synap5e Sep 16, 2026
6469df8
feat(assets): log terminal registration failures at warning
synap5e Sep 16, 2026
2081ead
fix(assets): keep WAL and the writer engine behind --enable-assets
synap5e Sep 17, 2026
5a99ccf
fix(assets): stat catalogued references outside the writer lease
synap5e Sep 17, 2026
39b50af
fix(assets): refuse an unsettled upload instead of mixing file facts
synap5e Sep 17, 2026
b1d44f2
docs(assets): correct the retry regime and record the WAL gate
synap5e Sep 17, 2026
d072527
test(database): show BEGIN-time contention spends the deadline pollin…
synap5e Sep 17, 2026
ea9126e
fix(database): make asset reader connections query_only
synap5e Sep 17, 2026
bf302b7
fix(assets): seed in bounded write transactions with stats taken befo…
synap5e Sep 17, 2026
e3200b3
fix(assets): refuse uploads that keep changing instead of hashing und…
synap5e Sep 17, 2026
d4bbeeb
fix(assets): seed write-lock barrier fixtures through the writer engine
synap5e Sep 17, 2026
51a4da5
fix(assets): reconcile references in bounded write transactions that …
synap5e Sep 17, 2026
838c696
fix(assets): enrich assets in bounded transactions with a row compare…
synap5e Sep 18, 2026
6c59da5
fix(assets): verify content from the preflight snapshot without re-st…
synap5e Sep 18, 2026
d4f64f7
fix(assets): compare rows instead of re-stating files inside upload a…
synap5e Sep 18, 2026
3604ba4
test(assets): let the event-log manifest express a call site's real m…
synap5e Sep 18, 2026
dcec255
docs(assets): document bounded write transactions and the read-only r…
synap5e Sep 18, 2026
b9c428b
chore(assets): drop the unused asset_meta table from migration 0007
synap5e Sep 15, 2026
02842ce
fix(assets): drop asset_meta when downgrading a database that already…
synap5e Sep 16, 2026
2e942be
docs(assets): clarify asset schema docstring
synap5e Sep 16, 2026
7eaabb5
test(assets): assert alembic and ORM index parity for the surviving a…
synap5e Sep 16, 2026
d9d78b9
test(assets): cover asset system state index parity
synap5e Sep 16, 2026
898348b
refactor(tests): hoist migration-0007 test imports to module scope
synap5e Sep 17, 2026
560a1ff
fix(assets): guard the hashing dependency and chain the real import e…
synap5e Sep 15, 2026
8fffd7d
fix(assets): always resume background scanning when prompt handling f…
synap5e Sep 15, 2026
9f2b470
fix(api): derive the assets feature flag from the selected manager
synap5e Sep 15, 2026
0297d72
fix(assets): paginate enrichment by id cursor so failures cannot star…
synap5e Sep 15, 2026
ac1b25e
refactor(assets): extract prompt_worker so its resume contract is tes…
synap5e Sep 15, 2026
5ce8742
refactor(assets): test the blake3 import guard in-process instead of …
synap5e Sep 15, 2026
e30769a
fix(assets): advance the enrichment cursor only past rows the batch a…
synap5e Sep 16, 2026
3af6baf
fix(assets): track the scan pause across prompt worker iterations
synap5e Sep 16, 2026
a787e25
chore(assets): address review follow-ups in the hashing guard, featur…
synap5e Sep 16, 2026
8584704
docs(assets): describe enrichment rows as attempted rather than selected
synap5e Sep 17, 2026
1f30b3a
test(api): derive the expected assets flag from the manager under test
synap5e Sep 17, 2026
fba73d1
test(assets): let a broken prompt worker import fail instead of skipping
synap5e Sep 17, 2026
bca8ad7
docs(assets): name the unattempted rows instead of the ones behind th…
synap5e Sep 17, 2026
d698d2a
fix(assets): only absorb duplicate-path races when seeding scanned as…
synap5e Sep 16, 2026
6bc286c
fix(db): copy the legacy database inside the process lock
synap5e Sep 16, 2026
9c6749a
fix(assets): drop watch-list entries on stat errors instead of aborti…
synap5e Sep 16, 2026
417532d
fix(assets): clean up temp uploads on validation failures
synap5e Sep 16, 2026
9b8e766
fix(assets): keep updated_at stable on no-op renames
synap5e Sep 16, 2026
a6b508b
docs(assets): make module docstrings and the rebuild warning truthful
synap5e Sep 16, 2026
68c7893
fix(assets): correct event-log status snapshots and failure telemetry
synap5e Sep 16, 2026
e13f0e1
test(assets): make the keyset tie-breaker and temp-exclusion tests fa…
synap5e Sep 16, 2026
2a89925
chore: comment cleanup
synap5e Sep 16, 2026
c605a9b
fix(assets): keep unreadable filesystem metadata from failing a whole…
synap5e Sep 16, 2026
d496e23
fix(assets): remove temporary uploads on non-UploadError failures
synap5e Sep 16, 2026
80682d0
fix(assets): preserve successful specs when a scan batch fault propag…
synap5e Sep 16, 2026
1eac742
test(db): drop the inert legacy-copy patch from the path preparation …
synap5e Sep 16, 2026
1f34110
fix(assets): report specs committed before a batch fault and preserve…
synap5e Sep 16, 2026
e9b3587
fix(assets): distinguish partial batch insert failures
synap5e Sep 16, 2026
d071895
refactor(assets): collapse the duplicated batch fault deferral into s…
synap5e Sep 16, 2026
c0e5ecc
fix(assets): reject duplicate file parts instead of stranding the fir…
synap5e Sep 16, 2026
64d884a
refactor(assets): reap empty upload directories without importing the…
synap5e Sep 16, 2026
e0286a6
fix(assets): emit the invalid-mtime event once per scan
synap5e Sep 17, 2026
e9af384
fix(assets): keep spec construction failures from wedging the watch list
synap5e Sep 17, 2026
f8b47bb
refactor(assets): export the live-path conflict check as public API
synap5e Sep 17, 2026
195045d
docs(db): restore the rationale for locking before migration
synap5e Sep 17, 2026
6b1deb5
fix(assets): stop a scan on memory exhaustion instead of deferring it
synap5e Sep 17, 2026
f88d266
docs(assets): document the prune failure response and its None result
synap5e Sep 17, 2026
f6a4646
test(assets): assert the surviving spec count after a propagated fault
synap5e Sep 17, 2026
4f37155
docs(db): shorten the lock-ordering comment while keeping its rationale
synap5e Sep 17, 2026
c021499
docs(tests): drop the cross-module justification from the import-orde…
synap5e Sep 17, 2026
8db7f91
test(assets): restore the cpu flag after the guarded prompt worker im…
synap5e Sep 18, 2026
ca8aba7
test(assets): restore the cpu flag even when the prompt worker import…
synap5e Sep 18, 2026
c1376aa
Correct the batch-size rationale in the write-transaction docs
synap5e Sep 18, 2026
2b24381
Base the batch-size rationale on measured commit cost
synap5e Sep 18, 2026
98195ca
Disable assets instead of exiting when the database fails to initialize
synap5e Sep 18, 2026
0aaeaa4
Stop scans and ingest when assets are disabled, and bound the transit…
synap5e Sep 18, 2026
b3fbe18
Store the published file's own stat and reconcile uploads in one tran…
synap5e Sep 18, 2026
91c9e87
Delete unreachable scanner and lookup helpers and correct their docum…
synap5e Sep 18, 2026
848defa
Attach the seeder spy the disable regression test asserts on
synap5e Sep 18, 2026
7be2922
Remove tests that pass against the behaviour they claim to cover
synap5e Sep 18, 2026
9c2b9fe
Drop the write-lock barrier suite that could not observe filesystem a…
synap5e Sep 18, 2026
104a98c
Parametrise the ingest refusal and registration-failure contracts
synap5e Sep 18, 2026
6001ca4
Explain why chunking survives a long lock hold that one transaction w…
synap5e Sep 18, 2026
4667fb4
Correct how a write lock conflict actually fails
synap5e Sep 19, 2026
4be78b1
Remove test guards that hid renames and trim the batch-size rationale
synap5e Sep 19, 2026
61662a0
Keep retryable filesystem errors inside the upload retry loops
synap5e Sep 19, 2026
20bcd61
Leave a row alone when the hash-mode transition loses its compare-and…
synap5e Sep 19, 2026
f2292dc
Remove the last getattr-spelled compatibility guard
synap5e Sep 20, 2026
6f12cb5
Retry a failed destination stat instead of skipping settlement
synap5e Sep 21, 2026
de70094
Merge remote-tracking branch 'origin/synap5e/fix/assets-sqlite-write-…
synap5e Sep 21, 2026
7e33627
Merge #16358 (sqlite write transactions) with #16393 (catalogue harde…
synap5e Sep 21, 2026
9d71c9b
Fix paused asset reference sync
synap5e Sep 21, 2026
1adfd4b
Reconcile the merge's cross-PR API changes in tests and the watch list
synap5e Sep 21, 2026
9537e39
Merge remote-tracking branch 'origin/synap5e/fix/assets-sqlite-write-…
synap5e Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions alembic_db/versions/0007_record_content_split.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,6 @@ def upgrade() -> None:
op.create_index("ix_assets_name", "assets", ["name"])
op.create_index("ix_assets_created_at", "assets", ["created_at"])
op.create_index("ix_assets_preview_id", "assets", ["preview_id"])
op.create_table(
"asset_meta",
sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True),
sa.Column("key", sa.String(256), primary_key=True),
sa.Column("ordinal", sa.Integer(), primary_key=True),
sa.Column("val_str", sa.String(2048)), sa.Column("val_num", sa.Numeric(38, 10)),
sa.Column("val_bool", sa.Boolean()), sa.Column("val_json", sa.JSON()),
sa.CheckConstraint("val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", name="ck_asset_meta_has_value"),
)
op.create_index("ix_asset_meta_key", "asset_meta", ["key"])
op.create_index("ix_asset_meta_key_val_str", "asset_meta", ["key", "val_str"])
op.create_index("ix_asset_meta_key_val_num", "asset_meta", ["key", "val_num"])
op.create_index("ix_asset_meta_key_val_bool", "asset_meta", ["key", "val_bool"])
op.create_table("asset_tags", sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True), sa.Column("tag_name", sa.String(512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True), sa.Column("origin", sa.String(32), nullable=False), sa.Column("added_at", sa.DateTime(), nullable=False))
op.create_index("ix_asset_tags_tag_name", "asset_tags", ["tag_name"])
op.create_index("ix_asset_tags_asset_id", "asset_tags", ["asset_id"])
Expand All @@ -89,7 +76,7 @@ def upgrade() -> None:
def downgrade() -> None:
op.drop_table("asset_system_state")
op.drop_table("asset_tags")
op.drop_table("asset_meta")
op.execute("DROP TABLE IF EXISTS asset_meta")
op.drop_table("assets")
op.drop_table("asset_contents")
op.create_table(
Expand Down
14 changes: 14 additions & 0 deletions app/assets/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ def register_assets_routes(
app.add_routes(ROUTES)


def close_assets_feature_gate() -> None:
"""Answer 503 from routes that are already registered."""
global _ASSETS_ENABLED
_ASSETS_ENABLED = False


def _build_error_response(
status: int, code: str, message: str, details: dict | None = None
) -> web.Response:
Expand Down Expand Up @@ -1126,6 +1132,8 @@ async def mark_missing_assets(request: web.Request) -> web.Response:
Returns:
200 OK with count of marked assets
409 Conflict if a scan is currently running
500 Internal Server Error with PRUNE_FAILED if the marking failed, so a
prune that did not run is never reported as a completed one
"""
try:
marked = asset_seeder.mark_missing_outside_prefixes()
Expand All @@ -1134,4 +1142,10 @@ async def mark_missing_assets(request: web.Request) -> web.Response:
{"status": "scan_running", "marked": 0},
status=409,
)
if marked is None:
return _build_error_response(
500,
"PRUNE_FAILED",
"Failed to mark missing assets.",
)
return web.json_response({"status": "completed", "marked": marked}, status=200)
180 changes: 96 additions & 84 deletions app/assets/api/upload.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Reads a multipart upload off the wire and lands its bytes in a temporary file
the ingest service can hash and move. The body is consumed in chunks so a large
model never has to fit in memory, and the temporary file is removed on every
failure path so an abandoned upload leaves nothing behind. Field values are
validated as they arrive, letting a bad request fail before its bytes are
written.
model never has to fit in memory. Validation failures remove any temporary file
before they are reported to the caller.
"""

import logging
Expand Down Expand Up @@ -65,101 +63,115 @@ async def parse_multipart_upload(

file_written = 0
tmp_path: str | None = None
parse_succeeded = False

while True:
field = await reader.next()
if field is None:
break

fname = getattr(field, "name", "") or ""
try:
while True:
field = await reader.next()
if field is None:
break

if fname == "hash":
try:
s = ((await field.text()) or "").strip().lower()
except Exception:
raise UploadError(
400, "INVALID_HASH", "hash must be like 'blake3:<hex>'"
)
fname = getattr(field, "name", "") or ""

if s:
provided_hash = normalize_and_validate_hash(s)
if fname == "hash":
try:
provided_hash_exists = check_hash_exists(provided_hash)
except Exception as e:
logging.exception(
"check_hash_exists failed for hash=%s: %s", provided_hash, e
s = ((await field.text()) or "").strip().lower()
except Exception:
raise UploadError(
400, "INVALID_HASH", "hash must be like 'blake3:<hex>'"
)

if s:
provided_hash = normalize_and_validate_hash(s)
try:
provided_hash_exists = check_hash_exists(provided_hash)
except Exception as e:
logging.exception(
"check_hash_exists failed for hash=%s: %s", provided_hash, e
)
raise UploadError(
500,
"HASH_CHECK_FAILED",
"Backend error while checking asset hash.",
)

elif fname == "file":
if file_present:
raise UploadError(
400,
"UNSUPPORTED_FIELD",
"Multiple 'file' parts are not supported.",
)
file_present = True
file_client_name = (field.filename or "").strip()

uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads")
unique_dir = os.path.join(uploads_root, uuid.uuid4().hex)
os.makedirs(unique_dir, exist_ok=True)
tmp_path = os.path.join(unique_dir, ".upload.part")

try:
with open(tmp_path, "wb") as f:
while True:
chunk = await field.read_chunk(8 * 1024 * 1024)
if not chunk:
break
f.write(chunk)
file_written += len(chunk)
except Exception:
raise UploadError(
500,
"HASH_CHECK_FAILED",
"Backend error while checking asset hash.",
500, "UPLOAD_IO_ERROR", "Failed to receive and store uploaded file."
)

elif fname == "file":
file_present = True
file_client_name = (field.filename or "").strip()

uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads")
unique_dir = os.path.join(uploads_root, uuid.uuid4().hex)
os.makedirs(unique_dir, exist_ok=True)
tmp_path = os.path.join(unique_dir, ".upload.part")

try:
with open(tmp_path, "wb") as f:
while True:
chunk = await field.read_chunk(8 * 1024 * 1024)
if not chunk:
break
f.write(chunk)
file_written += len(chunk)
except Exception:
delete_temp_file_if_exists(tmp_path)
elif fname == "tags":
tags_raw.append((await field.text()) or "")
elif fname == "name":
provided_name = (await field.text()) or None
elif fname == "user_metadata":
user_metadata_raw = (await field.text()) or None
elif fname == "id":
raise UploadError(
500, "UPLOAD_IO_ERROR", "Failed to receive and store uploaded file."
400,
"UNSUPPORTED_FIELD",
"Client-provided 'id' is not supported. Asset IDs are assigned by the server.",
)
elif fname == "mime_type":
provided_mime_type = ((await field.text()) or "").strip() or None
elif fname == "preview_id":
provided_preview_id = ((await field.text()) or "").strip() or None

elif fname == "tags":
tags_raw.append((await field.text()) or "")
elif fname == "name":
provided_name = (await field.text()) or None
elif fname == "user_metadata":
user_metadata_raw = (await field.text()) or None
elif fname == "id":
if not file_present and not (provided_hash and provided_hash_exists):
raise UploadError(
400,
"UNSUPPORTED_FIELD",
"Client-provided 'id' is not supported. Asset IDs are assigned by the server.",
"MISSING_FILE",
"Form must include a 'file' part or a known 'hash'.",
)
elif fname == "mime_type":
provided_mime_type = ((await field.text()) or "").strip() or None
elif fname == "preview_id":
provided_preview_id = ((await field.text()) or "").strip() or None
if not file_present and not (provided_hash and provided_hash_exists):
raise UploadError(
400, "MISSING_FILE", "Form must include a 'file' part or a known 'hash'."
)

if (
file_present
and file_written == 0
and not (provided_hash and provided_hash_exists)
):
delete_temp_file_if_exists(tmp_path)
raise UploadError(400, "EMPTY_UPLOAD", "Uploaded file is empty.")

return ParsedUpload(
file_present=file_present,
file_written=file_written,
file_client_name=file_client_name,
tmp_path=tmp_path,
tags_raw=tags_raw,
provided_name=provided_name,
user_metadata_raw=user_metadata_raw,
provided_hash=provided_hash,
provided_hash_exists=provided_hash_exists,
provided_mime_type=provided_mime_type,
provided_preview_id=provided_preview_id,
)
if (
file_present
and file_written == 0
and not (provided_hash and provided_hash_exists)
):
raise UploadError(400, "EMPTY_UPLOAD", "Uploaded file is empty.")

parsed = ParsedUpload(
file_present=file_present,
file_written=file_written,
file_client_name=file_client_name,
tmp_path=tmp_path,
tags_raw=tags_raw,
provided_name=provided_name,
user_metadata_raw=user_metadata_raw,
provided_hash=provided_hash,
provided_hash_exists=provided_hash_exists,
provided_mime_type=provided_mime_type,
provided_preview_id=provided_preview_id,
)
parse_succeeded = True
return parsed
finally:
if not parse_succeeded:
delete_temp_file_if_exists(tmp_path)


def delete_temp_file_if_exists(tmp_path: str | None) -> None:
Expand Down
35 changes: 1 addition & 34 deletions app/assets/database/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Declares the asset schema: content rows describing bytes on disk, records
describing what a user sees, and the tag and metadata tables hanging off them.
describing what a user sees, and the asset_tags table hanging off them.
The split is the point — many records can name one content row, and retiring
content by marking it missing rather than deleting it is what keeps a path's
history intact. Constraints declared here, not application code, are what make
Expand All @@ -10,7 +10,6 @@

import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any

from sqlalchemy import (
Expand All @@ -21,8 +20,6 @@
DateTime,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
text,
Expand Down Expand Up @@ -103,9 +100,6 @@ class Asset(Base):
preview: Mapped[Asset | None] = relationship(
"Asset", foreign_keys=[preview_id], remote_side=lambda: [Asset.id]
)
metadata_entries: Mapped[list[AssetMeta]] = relationship(
back_populates="asset", cascade="all,delete-orphan", passive_deletes=True
)
tag_links: Mapped[list[AssetTag]] = relationship(
back_populates="asset", cascade="all,delete-orphan", passive_deletes=True
)
Expand All @@ -121,33 +115,6 @@ class Asset(Base):
)


class AssetMeta(Base):
__tablename__ = "asset_meta"

asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True
)
key: Mapped[str] = mapped_column(String(256), primary_key=True)
ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0)
val_str: Mapped[str | None] = mapped_column(String(2048))
val_num: Mapped[Decimal | None] = mapped_column(Numeric(38, 10))
val_bool: Mapped[bool | None] = mapped_column(Boolean)
val_json: Mapped[Any | None] = mapped_column(JSON)

asset: Mapped[Asset] = relationship(back_populates="metadata_entries")

__table_args__ = (
Index("ix_asset_meta_key", "key"),
Index("ix_asset_meta_key_val_str", "key", "val_str"),
Index("ix_asset_meta_key_val_num", "key", "val_num"),
Index("ix_asset_meta_key_val_bool", "key", "val_bool"),
CheckConstraint(
"val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL",
name="ck_asset_meta_has_value",
),
)


class AssetTag(Base):
__tablename__ = "asset_tags"

Expand Down
2 changes: 2 additions & 0 deletions app/assets/database/queries/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
delete_record,
fetch_record_tags,
get_record_by_id,
is_live_path_conflict,
list_records_page,
mark_content_missing,
rename_record,
Expand All @@ -27,6 +28,7 @@
"delete_record",
"fetch_record_tags",
"get_record_by_id",
"is_live_path_conflict",
"list_records_page",
"mark_content_missing",
"rename_record",
Expand Down
13 changes: 7 additions & 6 deletions app/assets/database/queries/records.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Owns every write to content rows, records and their tag links, plus the paged
reads that list them. Inserts that can lose a race — a content row at a path, a
"""Provides shared writes for content rows, records and tag links, plus the paged
reads that list records. Inserts that can lose a race — a content row at a path, a
tag, a tag link — run inside a savepoint and re-read the conflicting row, so a
concurrent writer settles the call instead of raising, while a genuine
constraint failure still surfaces. This is the sole writer of a content row's
Expand Down Expand Up @@ -47,7 +47,7 @@ class RecordPageSpec(NamedTuple):
_LIVE_PATH_UNIQUE_INDEX = "uq_asset_contents_path_live"


def _is_live_path_conflict(error: IntegrityError) -> bool:
def is_live_path_conflict(error: IntegrityError) -> bool:
orig = error.orig
message = str(orig)
postgres_names_the_index = getattr(getattr(orig, "diag", None), "constraint_name", None) == _LIVE_PATH_UNIQUE_INDEX
Expand All @@ -66,7 +66,7 @@ def create_content_reporting_insert(session: Session, path: str, hash: str | Non
session.flush()
return content, True
except IntegrityError as error:
if not _is_live_path_conflict(error):
if not is_live_path_conflict(error):
raise
winner = session.execute(sa.select(AssetContent).where(AssetContent.path == path, AssetContent.is_missing.is_(False))).scalar_one()
return winner, False
Expand Down Expand Up @@ -299,8 +299,9 @@ def rename_record(session: Session, id: str, name: str) -> Asset:
record = session.get(Asset, id)
if record is None:
raise LookupError(id)
record.name = name
record.updated_at = get_utc_now()
if record.name != name:
record.name = name
record.updated_at = get_utc_now()
session.flush()
return record

Expand Down
Loading
Loading