feat(deep-scan): IR-parity port — refreshed status + counter, re-queue completed paths in deep mode, honest UX — Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
"""import_batch.refreshed counter for deep-scan sidecar re-application
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
Create Date: 2026-05-25
|
||||||
|
|
||||||
|
Adds a `refreshed` counter to `import_batch`, mirroring the existing
|
||||||
|
`imported`/`skipped`/`failed`/`attachments` columns. Deep scan now
|
||||||
|
re-applies sidecar metadata to already-imported files (the IR feature
|
||||||
|
that didn't make the FC port the first time); a "refreshed" outcome
|
||||||
|
increments this counter so the UI can surface "X new, Y refreshed"
|
||||||
|
instead of the misleading "Scan complete — no new files" message.
|
||||||
|
|
||||||
|
server_default=0 backfills existing rows in place — no UPDATE needed.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0019"
|
||||||
|
down_revision: Union[str, None] = "0018"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"import_batch",
|
||||||
|
sa.Column(
|
||||||
|
"refreshed", sa.Integer(),
|
||||||
|
nullable=False, server_default=sa.text("0"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("import_batch", "refreshed")
|
||||||
@@ -53,6 +53,7 @@ async def status():
|
|||||||
"imported": active.imported,
|
"imported": active.imported,
|
||||||
"skipped": active.skipped,
|
"skipped": active.skipped,
|
||||||
"failed": active.failed,
|
"failed": active.failed,
|
||||||
|
"refreshed": active.refreshed,
|
||||||
"started_at": active.started_at.isoformat(),
|
"started_at": active.started_at.isoformat(),
|
||||||
}
|
}
|
||||||
return jsonify(payload)
|
return jsonify(payload)
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ class ImportBatch(Base):
|
|||||||
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Deep-scan only: count of already-imported files whose sidecar metadata
|
||||||
|
# got re-applied this run (post/source/provenance upsert). Stays 0 on
|
||||||
|
# quick-scan batches. See `Importer.import_one(deep_scan=True)`.
|
||||||
|
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
||||||
# running | complete | cancelled
|
# running | complete | cancelled
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ class SkipReason(StrEnum):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ImportResult:
|
class ImportResult:
|
||||||
status: str # 'imported'|'skipped'|'failed'|'superseded'|'attached'
|
# 'imported' — new ImageRecord row created
|
||||||
|
# 'superseded' — existing ImageRecord row got the new file (larger) + sidecar
|
||||||
|
# 'attached' — non-media saved as PostAttachment
|
||||||
|
# 'refreshed' — deep scan re-applied sidecar / filled NULL phash / NULL
|
||||||
|
# artist on an already-imported row (no new ImageRecord)
|
||||||
|
# 'skipped' — no work done (true duplicate, too small, etc.)
|
||||||
|
# 'failed' — pipeline error
|
||||||
|
status: str
|
||||||
image_id: int | None = None
|
image_id: int | None = None
|
||||||
skip_reason: SkipReason | None = None
|
skip_reason: SkipReason | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
@@ -417,7 +424,18 @@ class Importer:
|
|||||||
) -> ImportResult:
|
) -> ImportResult:
|
||||||
"""Deep scan: backfill phash/provenance/artist on an
|
"""Deep scan: backfill phash/provenance/artist on an
|
||||||
already-imported record. METADATA ONLY — never re-runs the pHash
|
already-imported record. METADATA ONLY — never re-runs the pHash
|
||||||
near-dup / supersede path. NULL-only, idempotent."""
|
near-dup / supersede path. NULL-only on phash/artist, additive on
|
||||||
|
sidecar Post/Source/ImageProvenance (via _apply_sidecar).
|
||||||
|
Idempotent: a second deep-scan over the same file finds nothing
|
||||||
|
to refresh and is a no-op.
|
||||||
|
|
||||||
|
Returns status="refreshed" so the UI can surface the work done
|
||||||
|
instead of the prior misleading "skipped/duplicate_hash" reading.
|
||||||
|
Operator-flagged 2026-05-25 — IR has had this; FC inherited it
|
||||||
|
as a no-op skip during the original port and the UI showed deep
|
||||||
|
scan as "completed with no changes" even when sidecar metadata
|
||||||
|
actually got re-applied to N existing rows.
|
||||||
|
"""
|
||||||
if existing.phash is None and not is_video(source):
|
if existing.phash is None and not is_video(source):
|
||||||
try:
|
try:
|
||||||
with Image.open(source) as im:
|
with Image.open(source) as im:
|
||||||
@@ -436,10 +454,7 @@ class Importer:
|
|||||||
|
|
||||||
self._apply_sidecar(existing, attribution_path, artist)
|
self._apply_sidecar(existing, attribution_path, artist)
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
return ImportResult(
|
return ImportResult(status="refreshed", image_id=existing.id)
|
||||||
status="skipped", skip_reason=SkipReason.duplicate_hash,
|
|
||||||
image_id=existing.id, error="deep: re-derived",
|
|
||||||
)
|
|
||||||
|
|
||||||
def attach_in_place(
|
def attach_in_place(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ IMAGES_ROOT = Path("/images")
|
|||||||
def _map_result_to_status(result):
|
def _map_result_to_status(result):
|
||||||
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
"""(ImportTask.status, should_requeue_ml_and_thumb) for an ImportResult.
|
||||||
'superseded' = the kept row's file/ML changed → complete + re-derive.
|
'superseded' = the kept row's file/ML changed → complete + re-derive.
|
||||||
'attached' = a non-art file preserved → complete, no ML/thumb."""
|
'attached' = a non-art file preserved → complete, no ML/thumb.
|
||||||
|
'refreshed' = deep scan refreshed sidecar/phash on an existing row →
|
||||||
|
complete, no ML/thumb re-derive (file/pixels unchanged)."""
|
||||||
if result.status in ("imported", "superseded"):
|
if result.status in ("imported", "superseded"):
|
||||||
return ("complete", True)
|
return ("complete", True)
|
||||||
if result.status == "attached":
|
if result.status in ("attached", "refreshed"):
|
||||||
return ("complete", False)
|
return ("complete", False)
|
||||||
if result.status == "skipped":
|
if result.status == "skipped":
|
||||||
return ("skipped", False)
|
return ("skipped", False)
|
||||||
@@ -138,6 +140,15 @@ def _do_import(session, task, import_task_id: int) -> dict:
|
|||||||
task.result_image_id = result.image_id
|
task.result_image_id = result.image_id
|
||||||
counter_col_name = "imported"
|
counter_col_name = "imported"
|
||||||
counter_col = ImportBatch.imported
|
counter_col = ImportBatch.imported
|
||||||
|
elif result.status == "refreshed":
|
||||||
|
# Deep-scan rederive: existing row got phash/artist/sidecar
|
||||||
|
# refreshed. Task is complete (no further work), but counted in
|
||||||
|
# `refreshed` not `imported` so the UI can surface the actual
|
||||||
|
# work done. operator-flagged 2026-05-25.
|
||||||
|
task.status = "complete"
|
||||||
|
task.result_image_id = result.image_id
|
||||||
|
counter_col_name = "refreshed"
|
||||||
|
counter_col = ImportBatch.refreshed
|
||||||
elif result.status == "attached":
|
elif result.status == "attached":
|
||||||
task.status = "complete"
|
task.status = "complete"
|
||||||
counter_col_name = "attachments"
|
counter_col_name = "attachments"
|
||||||
|
|||||||
@@ -59,16 +59,25 @@ def scan_directory(self, triggered_by: str = "manual",
|
|||||||
session.flush()
|
session.flush()
|
||||||
batch_id = batch.id
|
batch_id = batch.id
|
||||||
|
|
||||||
# Skip-set: any source_path that already has a non-failed ImportTask
|
# Skip-set behavior splits by mode (operator-flagged 2026-05-25):
|
||||||
# row. Re-running scan_directory must not re-enqueue files the
|
#
|
||||||
# importer has already handled (or is currently handling); doing so
|
# quick: any non-failed prior ImportTask (active OR finished) is
|
||||||
# creates duplicate work and inflates the queue. Failed prior tasks
|
# skipped — quick scan only does new-file enqueue, so re-touching
|
||||||
# are eligible for retry.
|
# already-imported files is wasted work.
|
||||||
|
#
|
||||||
|
# deep: ONLY currently-in-flight tasks (pending/queued/processing)
|
||||||
|
# are skipped. Completed and skipped tasks ARE re-queued because
|
||||||
|
# deep scan exists precisely to re-touch already-imported files
|
||||||
|
# (refresh sidecar metadata, fill NULL phash, fill NULL artist
|
||||||
|
# via Importer._deep_rederive). Matches IR's deep-scan behavior.
|
||||||
|
active_statuses = ["pending", "queued", "processing"]
|
||||||
|
if mode == "deep":
|
||||||
|
skip_statuses = active_statuses
|
||||||
|
else:
|
||||||
|
skip_statuses = active_statuses + ["complete", "skipped"]
|
||||||
non_failed_existing = set(session.execute(
|
non_failed_existing = set(session.execute(
|
||||||
select(ImportTask.source_path).where(
|
select(ImportTask.source_path).where(
|
||||||
ImportTask.status.in_(
|
ImportTask.status.in_(skip_statuses),
|
||||||
["pending", "queued", "processing", "complete", "skipped"]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
).scalars().all())
|
).scalars().all())
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
|
||||||
{{ store.activeBatch.source_path || '/import' }} —
|
{{ store.activeBatch.source_path || '/import' }} —
|
||||||
imported {{ store.activeBatch.imported }},
|
imported {{ store.activeBatch.imported }},
|
||||||
|
<template v-if="store.activeBatch.scan_mode === 'deep'">
|
||||||
|
refreshed {{ store.activeBatch.refreshed || 0 }},
|
||||||
|
</template>
|
||||||
skipped {{ store.activeBatch.skipped }},
|
skipped {{ store.activeBatch.skipped }},
|
||||||
failed {{ store.activeBatch.failed }} /
|
failed {{ store.activeBatch.failed }} /
|
||||||
{{ store.activeBatch.total_files }} files
|
{{ store.activeBatch.total_files }} files
|
||||||
@@ -26,11 +29,12 @@
|
|||||||
<p class="text-body-2 mb-3">
|
<p class="text-body-2 mb-3">
|
||||||
<span v-if="!store.activeBatch">
|
<span v-if="!store.activeBatch">
|
||||||
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
|
||||||
new files only (skips paths already on a non-failed ImportTask).
|
new files only.
|
||||||
<strong>Deep scan</strong> additionally chains a phash backfill
|
<strong>Deep scan</strong> additionally re-walks already-imported
|
||||||
across the existing library — use after bulk-imports to catch
|
files so updated sidecar metadata (post title/date/attribution) and
|
||||||
near-duplicates that slipped through. Both modes route non-media
|
previously-NULL phashes / artist links get refreshed. Use after
|
||||||
+ sidecar pairs through PostAttachment capture.
|
bulk-downloading fresh sidecars for existing content. Both modes
|
||||||
|
route non-media + sidecar pairs through PostAttachment capture.
|
||||||
</span>
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
An active batch is in progress. Wait for it to finish, or click
|
An active batch is in progress. Wait for it to finish, or click
|
||||||
|
|||||||
@@ -66,21 +66,42 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
// batch flashes 'running' for <100ms then 'complete' before the
|
// batch flashes 'running' for <100ms then 'complete' before the
|
||||||
// first refreshStatus() lands; UI never sees the active state).
|
// first refreshStatus() lands; UI never sees the active state).
|
||||||
const label = mode === 'deep'
|
const label = mode === 'deep'
|
||||||
? 'Deep scan triggered (pHash backfill chained)'
|
? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)'
|
||||||
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
|
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
|
||||||
window.__fcToast?.({ text: label, type: 'success' })
|
window.__fcToast?.({ text: label, type: 'success' })
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
// Re-poll twice over ~5s to catch quick-finalize transitions and
|
// Re-poll twice over ~5s and produce an HONEST follow-up toast.
|
||||||
// surface a result toast either way. Skip the "no new files" hint
|
// Operator-flagged 2026-05-25: the prior "no new files" message was
|
||||||
// for 'verify' since it doesn't walk the import_root.
|
// misleading because deep scan IS doing work (refresh) even when
|
||||||
|
// there are no new files to import. Surface the real workload count
|
||||||
|
// (imported + refreshed + queued) instead. For quick scan + zero
|
||||||
|
// queued work, fall back to "up to date" instead of the old
|
||||||
|
// implementation-detail-leaking message.
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
await loadTasks(true)
|
await loadTasks(true)
|
||||||
if (!activeBatch.value && mode !== 'verify') {
|
if (activeBatch.value || mode === 'verify') return
|
||||||
|
// Batch finalized quickly; figure out what actually happened.
|
||||||
|
// The task list was just refreshed; the freshest row(s) carry
|
||||||
|
// the batch outcome.
|
||||||
|
const batchId = tasks.value[0]?.batch_id
|
||||||
|
const sameBatch = batchId
|
||||||
|
? tasks.value.filter(t => t.batch_id === batchId)
|
||||||
|
: []
|
||||||
|
const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length
|
||||||
|
const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length
|
||||||
|
if (mode === 'deep' && sameBatch.length > 0) {
|
||||||
window.__fcToast?.({
|
window.__fcToast?.({
|
||||||
text: 'Scan complete — no new files (everything already on an ImportTask row)',
|
text: `Deep scan finished — ${sameBatch.length} file(s) processed`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
})
|
})
|
||||||
|
} else if (mode === 'quick' && sameBatch.length > 0) {
|
||||||
|
window.__fcToast?.({
|
||||||
|
text: `Quick scan finished — ${newImported} new file(s) queued`,
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window.__fcToast?.({ text: 'Library is up to date', type: 'info' })
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -64,9 +64,13 @@ def test_deep_rederives_phash_and_provenance(db_sync, import_layout):
|
|||||||
|
|
||||||
deep = _mk(db_sync, import_layout, deep=True)
|
deep = _mk(db_sync, import_layout, deep=True)
|
||||||
r2 = deep.import_one(src)
|
r2 = deep.import_one(src)
|
||||||
assert r2.status == "skipped"
|
# Outcome flipped from "skipped+duplicate_hash" to "refreshed" 2026-05-25
|
||||||
assert "deep" in (r2.error or "")
|
# so the UI can surface deep scan's actual work instead of showing it as
|
||||||
|
# a no-op. See ImportResult.status comment + _deep_rederive docstring.
|
||||||
|
assert r2.status == "refreshed"
|
||||||
assert r2.image_id == rec.id
|
assert r2.image_id == rec.id
|
||||||
|
assert r2.skip_reason is None
|
||||||
|
assert r2.error is None
|
||||||
|
|
||||||
db_sync.expire_all()
|
db_sync.expire_all()
|
||||||
rec2 = db_sync.get(ImageRecord, rec.id)
|
rec2 = db_sync.get(ImageRecord, rec.id)
|
||||||
|
|||||||
@@ -241,3 +241,9 @@ def test_import_task_maps_superseded_to_complete_and_requeues():
|
|||||||
assert _map_result_to_status(
|
assert _map_result_to_status(
|
||||||
ImportResult(status="failed", error="boom")
|
ImportResult(status="failed", error="boom")
|
||||||
) == ("failed", False)
|
) == ("failed", False)
|
||||||
|
# Refreshed (deep scan): complete + no ML/thumb re-derive (pixels
|
||||||
|
# unchanged). Added 2026-05-25 alongside ImportBatch.refreshed
|
||||||
|
# counter so deep scan reports "X refreshed" instead of "no work".
|
||||||
|
assert _map_result_to_status(
|
||||||
|
ImportResult(status="refreshed", image_id=5)
|
||||||
|
) == ("complete", False)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Deep scan re-queues already-completed ImportTasks.
|
||||||
|
|
||||||
|
Operator-flagged 2026-05-25: deep scan used to skip everything that
|
||||||
|
already had a non-failed ImportTask row, making a deep re-scan a no-op
|
||||||
|
when no new files were added. That defeated the entire point of deep
|
||||||
|
scan (re-apply sidecar metadata to existing rows). The skip-set now
|
||||||
|
splits by mode — quick keeps the old "any non-failed" semantics; deep
|
||||||
|
skips ONLY actively-in-flight statuses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from backend.app.models import ImportBatch, ImportSettings, ImportTask
|
||||||
|
from backend.app.tasks.scan import scan_directory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
def _img(path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (40, 40), (10, 200, 80)).save(path, "JPEG")
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_scan_requeues_completed_task(db_sync, tmp_path, monkeypatch):
|
||||||
|
"""Quick scan then deep scan of the same /import: the file completed
|
||||||
|
in the first run should be re-enqueued by the deep run (different
|
||||||
|
ImportTask id, same source_path)."""
|
||||||
|
import_root = tmp_path / "import"
|
||||||
|
settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
settings.import_scan_path = str(import_root)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
src = import_root / "Mae" / "p.jpg"
|
||||||
|
_img(src)
|
||||||
|
|
||||||
|
from backend.app import celery_app
|
||||||
|
|
||||||
|
celery_app.celery.conf.task_always_eager = False # explicit
|
||||||
|
try:
|
||||||
|
first_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||||
|
first_task = db_sync.execute(
|
||||||
|
select(ImportTask).where(ImportTask.batch_id == first_batch_id)
|
||||||
|
).scalar_one()
|
||||||
|
# Simulate the worker having finished it.
|
||||||
|
first_task.status = "complete"
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
# Now deep scan: the SAME file should get a NEW ImportTask row.
|
||||||
|
second_batch_id = scan_directory.run(triggered_by="manual", mode="deep")
|
||||||
|
assert second_batch_id != first_batch_id
|
||||||
|
|
||||||
|
new_tasks_in_second_batch = db_sync.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ImportTask)
|
||||||
|
.where(ImportTask.batch_id == second_batch_id)
|
||||||
|
).scalar_one()
|
||||||
|
assert new_tasks_in_second_batch == 1, (
|
||||||
|
"deep scan did not re-queue the completed file"
|
||||||
|
)
|
||||||
|
|
||||||
|
# And the second batch's task should be for the same source_path
|
||||||
|
# as the first (proves it's a re-queue, not a different file).
|
||||||
|
sp = db_sync.execute(
|
||||||
|
select(ImportTask.source_path)
|
||||||
|
.where(ImportTask.batch_id == second_batch_id)
|
||||||
|
).scalar_one()
|
||||||
|
assert sp == str(src)
|
||||||
|
finally:
|
||||||
|
celery_app.celery.conf.task_always_eager = False
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_scan_does_not_requeue_completed_task(db_sync, tmp_path):
|
||||||
|
"""The flip side: quick scan still keeps the old skip semantics —
|
||||||
|
a file with a completed ImportTask row from a prior batch is NOT
|
||||||
|
re-enqueued on a fresh quick scan."""
|
||||||
|
import_root = tmp_path / "import"
|
||||||
|
settings = db_sync.execute(
|
||||||
|
select(ImportSettings).where(ImportSettings.id == 1)
|
||||||
|
).scalar_one()
|
||||||
|
settings.import_scan_path = str(import_root)
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
src = import_root / "Mae" / "q.jpg"
|
||||||
|
_img(src)
|
||||||
|
|
||||||
|
first_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||||
|
first_task = db_sync.execute(
|
||||||
|
select(ImportTask).where(ImportTask.batch_id == first_batch_id)
|
||||||
|
).scalar_one()
|
||||||
|
first_task.status = "complete"
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
second_batch_id = scan_directory.run(triggered_by="manual", mode="quick")
|
||||||
|
second_batch_count = db_sync.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ImportTask)
|
||||||
|
.where(ImportTask.batch_id == second_batch_id)
|
||||||
|
).scalar_one()
|
||||||
|
assert second_batch_count == 0, (
|
||||||
|
"quick scan re-queued an already-completed task — should have skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
# And the second batch should self-finalize (files_seen=0).
|
||||||
|
second_batch = db_sync.get(ImportBatch, second_batch_id)
|
||||||
|
assert second_batch.status == "complete"
|
||||||
Reference in New Issue
Block a user