diff --git a/spp_api_v2/models/ir_http_patch.py b/spp_api_v2/models/ir_http_patch.py index badd9f7d..ba6e2e4b 100644 --- a/spp_api_v2/models/ir_http_patch.py +++ b/spp_api_v2/models/ir_http_patch.py @@ -8,6 +8,7 @@ This workaround should be removed when Odoo core fixes the cache bug. """ +import hashlib import logging import threading @@ -22,6 +23,16 @@ _logger = logging.getLogger(__name__) +# Stable 64-bit signed key for the transaction-scoped advisory lock that +# serializes FastAPI endpoint sync attempts across workers. Derived from a +# SHA-256 of the qualified name so it is deterministic and unlikely to collide +# with other modules' advisory locks in the same database. +_FASTAPI_SYNC_ADVISORY_LOCK_KEY = int.from_bytes( + hashlib.sha256(b"spp_api_v2.fastapi_endpoint_sync").digest()[:8], + byteorder="big", + signed=True, +) + class IrHttp(models.AbstractModel): """Patch ir.http to fix routing_map cache bug""" @@ -80,37 +91,53 @@ def routing_map(self, key=None): from odoo.api import Environment with registry.cursor() as cr: - env = Environment(cr, SUPERUSER_ID, {}) - - # First check for endpoints with registry_sync=False (never synced) - unsynced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", False)]) - - # Also check for endpoints that claim to be synced but have no routes - # This catches cases where routes were deleted or DB was reset - synced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", True)]) - if synced_endpoints and "endpoint.route" in env: - for endpoint in synced_endpoints: - route_exists = env["endpoint.route"].search_count( - [("endpoint_id", "=", endpoint.id)], limit=1 - ) - if not route_exists: - _logger.warning( - "Endpoint '%s' (id=%d) claims to be synced but has no routes - forcing re-sync", - endpoint.name, - endpoint.id, - ) - # Reset flag to trigger re-sync - endpoint.registry_sync = False - unsynced_endpoints |= endpoint - - if unsynced_endpoints: - unsynced_endpoints.action_sync_registry() - cr.commit() - _logger.info( - "Synced %d FastAPI endpoints for database %s", - len(unsynced_endpoints), + # Serialize concurrent sync attempts across workers. After a + # registry reload (e.g. -u all) every worker's routing_map() + # races to update the same fastapi_endpoint rows; without + # this lock all but one fail with SerializationFailure. + # Transaction-scoped — released automatically at COMMIT/ROLLBACK. + cr.execute( + "SELECT pg_try_advisory_xact_lock(%s)", + (_FASTAPI_SYNC_ADVISORY_LOCK_KEY,), + ) + (got_lock,) = cr.fetchone() + if not got_lock: + _logger.debug( + "FastAPI endpoint sync skipped for %s — another worker is syncing", registry.db_name, ) + else: + env = Environment(cr, SUPERUSER_ID, {}) + + # First check for endpoints with registry_sync=False (never synced) + unsynced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", False)]) + + # Also check for endpoints that claim to be synced but have no routes + # This catches cases where routes were deleted or DB was reset + synced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", True)]) + if synced_endpoints and "endpoint.route" in env: + for endpoint in synced_endpoints: + route_exists = env["endpoint.route"].search_count( + [("endpoint_id", "=", endpoint.id)], limit=1 + ) + if not route_exists: + _logger.warning( + "Endpoint '%s' (id=%d) claims to be synced but has no routes - forcing re-sync", + endpoint.name, + endpoint.id, + ) + # Reset flag to trigger re-sync + endpoint.registry_sync = False + unsynced_endpoints |= endpoint + + if unsynced_endpoints: + unsynced_endpoints.action_sync_registry() + cr.commit() + _logger.info( + "Synced %d FastAPI endpoints for database %s", + len(unsynced_endpoints), + registry.db_name, + ) except Exception as e: # If endpoint model doesn't exist or sync fails, continue anyway _logger.debug("Could not sync FastAPI endpoints: %s", e)