Make a partly-undone import retryable, and refuse a plan it cannot deliver

This commit is contained in:
2026-09-03 09:16:01 -04:00
parent b0bd73f62d
commit ddf41c3e65
20 changed files with 588 additions and 37 deletions

View File

@@ -34,6 +34,8 @@ from datetime import datetime, timezone
BATCH_APPLIED = 'applied'
BATCH_UNDONE = 'undone'
# See shopdb/core/models/importbatch.py for why this exists.
BATCH_PARTIAL = 'partially-undone'
OP_UPDATE = 'update'
OP_INSERT = 'insert'
@@ -150,6 +152,9 @@ def undo_batch(connection, batchid, references=None):
{'batchid': batchid}).fetchone()
if batch is None:
raise ValueError('unknown batchid: %s' % batchid)
# Only a FULLY undone batch is finished. A partially-undone one still has
# entries with isreverted = 0, and the whole point of retrying is to pick
# them up once whatever conflicted has been resolved.
if batch[0] == BATCH_UNDONE:
return UndoResult(batchid, 0, [])
@@ -184,10 +189,20 @@ def undo_batch(connection, batchid, references=None):
else:
conflicts.append(conflict)
# Record what actually happened, not what was attempted. Writing 'undone'
# unconditionally is what made a conflicted entry unrevertable: this call
# is the only way back, and the early return above refuses a second attempt
# on an undone batch.
#
# undoneat is stamped only when the batch is genuinely finished; on a
# partial it stays null, so "when was this reversed" keeps its meaning.
status = BATCH_PARTIAL if conflicts else BATCH_UNDONE
connection.execute(text(
'UPDATE importbatches SET status = :status, undoneat = :undoneat '
'WHERE batchid = :batchid'),
{'status': BATCH_UNDONE, 'undoneat': _utcnow(), 'batchid': batchid})
{'status': status,
'undoneat': None if conflicts else _utcnow(),
'batchid': batchid})
return UndoResult(batchid, reverted, conflicts)
@@ -218,10 +233,19 @@ def _revert_update(connection, table, targetid, column, oldvalue, newvalue):
def _revert_insert(connection, batchid, table, targetid, references):
"""Hard-delete a row this batch created, only if nothing outside the batch
still references it. Returns a Conflict when it is not safe, else None."""
from sqlalchemy import text
from sqlalchemy import inspect, text
pk = _primary_key(connection, table)
# A reference table may not exist here. Some referrers are plugin-owned -
# notifications, for one - and a lean site drops the tables of plugins it
# does not install (ADR-014). Querying an absent table raises 1146 on MySQL,
# which rolls the whole undo back and leaves the batch permanently
# un-undoable on that site. Skip what is not there; a table that does not
# exist cannot hold a reference.
present = set(inspect(connection).get_table_names())
for reftable, refcolumn in (references or {}).get(table, []):
if reftable not in present:
continue
referencing = connection.execute(text(
'SELECT COUNT(*) FROM %s WHERE %s = :targetid'
% (reftable, refcolumn)), {'targetid': targetid}).fetchone()[0]