From 89a16171039cedb82a7386fa179a043fa851f1c3 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 28 Jul 2026 18:55:17 -0400 Subject: [PATCH] geenforce: fix re-publish FK crash on MySQL (stale entries in draft rebuild) replace_scope_draft deleted old draft entries with per-object db.session.delete but left the deleted objects in scope.entries. On a re-publish a caller (seed_display_scope) then matched a stale deleted entry via next() and store_inline_payload attached a payload to its dead entryid, failing the manifestpayloads->manifestentries FK on MySQL (1452); SQLite does not enforce it so the idempotency test passed. Clear the collection via the delete-orphan cascade instead, and flush pending inserts before the bulk payload delete so its autoflush cannot interleave a half-built insert. Verified publish + re-publish x3 on MySQL 5.6. --- plugins/geenforce/service.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index eff69f2..3fe3bc1 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -57,8 +57,14 @@ def replace_scope_draft(scopename, phase, manifest): scope.site = manifest.get('Site') scope.iscommon = (scopename == 'common') - for entry in list(scope.entries): - db.session.delete(entry) + # Clear existing draft entries via the delete-orphan cascade. This also + # EMPTIES the in-memory collection, so a later lookup on scope.entries cannot + # match a stale (deleted) entry. A per-object db.session.delete() leaves the + # deleted objects in scope.entries until expiry, which on a re-publish made a + # caller (seed_display_scope) pick an old deleted entry and attach a payload + # to its dead entryid -> MySQL FK 1452 (SQLite does not enforce it, so the + # idempotency test missed it). + scope.entries.clear() db.session.flush() for i, entry_dict in enumerate(manifest.get('Applications') or []): @@ -352,7 +358,15 @@ def store_inline_payload(entry, filename, contenttype, rawbytes): Returns the ManifestPayload. """ sha = hashlib.sha256(rawbytes).hexdigest() - ManifestPayload.query.filter_by(entryid=entry.entryid).delete() + # Flush any pending inserts (e.g. a prior entry's inline payload) BEFORE the + # bulk delete. Otherwise the delete's autoflush interleaves that half-built + # INSERT and, on MySQL, fails the manifestpayloads->manifestentries FK + # (SQLite does not enforce it the same way, so tests miss this). Then delete + # existing rows for this entry with autoflush off + no session-sync. + db.session.flush() + with db.session.no_autoflush: + ManifestPayload.query.filter_by( + entryid=entry.entryid).delete(synchronize_session=False) db.session.flush() payload = ManifestPayload( entryid=entry.entryid,