|
8 | 8 | This workaround should be removed when Odoo core fixes the cache bug. |
9 | 9 | """ |
10 | 10 |
|
| 11 | +import hashlib |
11 | 12 | import logging |
12 | 13 | import threading |
13 | 14 |
|
|
22 | 23 |
|
23 | 24 | _logger = logging.getLogger(__name__) |
24 | 25 |
|
| 26 | +# Stable 64-bit signed key for the transaction-scoped advisory lock that |
| 27 | +# serializes FastAPI endpoint sync attempts across workers. Derived from a |
| 28 | +# SHA-256 of the qualified name so it is deterministic and unlikely to collide |
| 29 | +# with other modules' advisory locks in the same database. |
| 30 | +_FASTAPI_SYNC_ADVISORY_LOCK_KEY = int.from_bytes( |
| 31 | + hashlib.sha256(b"spp_api_v2.fastapi_endpoint_sync").digest()[:8], |
| 32 | + byteorder="big", |
| 33 | + signed=True, |
| 34 | +) |
| 35 | + |
| 36 | + |
| 37 | +def _try_acquire_fastapi_sync_lock(cr): |
| 38 | + """Try to acquire the cross-worker FastAPI endpoint sync advisory lock. |
| 39 | +
|
| 40 | + Returns True if this transaction got the lock, False otherwise (either |
| 41 | + because another backend holds it, or because the lock SQL itself failed — |
| 42 | + e.g. exhausted shared-lock memory, permissions). The lock is transaction- |
| 43 | + scoped (released automatically at COMMIT/ROLLBACK), so callers do not need |
| 44 | + to release it explicitly. |
| 45 | +
|
| 46 | + Failing closed (returning False) is the safe default: callers will skip |
| 47 | + the sync, and the next routing_map call will retry. Logging at WARNING so |
| 48 | + a persistently broken lock primitive is visible — silently degrading to |
| 49 | + every-worker-races behaviour would mask the regression this patch fixes. |
| 50 | + """ |
| 51 | + try: |
| 52 | + cr.execute( |
| 53 | + "SELECT pg_try_advisory_xact_lock(%s)", |
| 54 | + (_FASTAPI_SYNC_ADVISORY_LOCK_KEY,), |
| 55 | + ) |
| 56 | + (got_lock,) = cr.fetchone() |
| 57 | + return got_lock |
| 58 | + except Exception as e: |
| 59 | + _logger.warning( |
| 60 | + "FastAPI endpoint sync advisory-lock acquire failed (%s); " |
| 61 | + "treating as 'lock held elsewhere' and skipping sync this round.", |
| 62 | + e, |
| 63 | + ) |
| 64 | + return False |
| 65 | + |
25 | 66 |
|
26 | 67 | class IrHttp(models.AbstractModel): |
27 | 68 | """Patch ir.http to fix routing_map cache bug""" |
@@ -80,37 +121,64 @@ def routing_map(self, key=None): |
80 | 121 | from odoo.api import Environment |
81 | 122 |
|
82 | 123 | with registry.cursor() as cr: |
83 | | - env = Environment(cr, SUPERUSER_ID, {}) |
84 | | - |
85 | | - # First check for endpoints with registry_sync=False (never synced) |
86 | | - unsynced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", False)]) |
87 | | - |
88 | | - # Also check for endpoints that claim to be synced but have no routes |
89 | | - # This catches cases where routes were deleted or DB was reset |
90 | | - synced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", True)]) |
91 | | - if synced_endpoints and "endpoint.route" in env: |
92 | | - for endpoint in synced_endpoints: |
93 | | - route_exists = env["endpoint.route"].search_count( |
94 | | - [("endpoint_id", "=", endpoint.id)], limit=1 |
95 | | - ) |
96 | | - if not route_exists: |
97 | | - _logger.warning( |
98 | | - "Endpoint '%s' (id=%d) claims to be synced but has no routes - forcing re-sync", |
99 | | - endpoint.name, |
100 | | - endpoint.id, |
101 | | - ) |
102 | | - # Reset flag to trigger re-sync |
103 | | - endpoint.registry_sync = False |
104 | | - unsynced_endpoints |= endpoint |
105 | | - |
106 | | - if unsynced_endpoints: |
107 | | - unsynced_endpoints.action_sync_registry() |
108 | | - cr.commit() |
| 124 | + # Serialize concurrent sync attempts across workers. After a |
| 125 | + # registry reload (e.g. -u all) every worker's routing_map() |
| 126 | + # races to update the same fastapi_endpoint rows; without |
| 127 | + # this lock all but one fail with SerializationFailure. |
| 128 | + if not _try_acquire_fastapi_sync_lock(cr): |
| 129 | + # Skipping is safe: the worker that DID get the lock will |
| 130 | + # bump endpoint_route_version when it commits action_sync_registry(). |
| 131 | + # That version is part of our routing-map cache key (line ~89), |
| 132 | + # and we re-read it per call, so any degraded routing map this |
| 133 | + # worker caches now is keyed at the old version and is naturally |
| 134 | + # invalidated on the next call after the winner commits. Bad |
| 135 | + # window is bounded by winner-commit latency (seconds at most). |
| 136 | + # |
| 137 | + # INFO (not DEBUG) so it's visible at default log level — |
| 138 | + # otherwise diagnosing transient missing routes after a |
| 139 | + # cold start has nothing to go on. Fires at most once |
| 140 | + # per registry reload per worker, so not noisy. |
109 | 141 | _logger.info( |
110 | | - "Synced %d FastAPI endpoints for database %s", |
111 | | - len(unsynced_endpoints), |
| 142 | + "FastAPI endpoint sync skipped for %s — another worker is syncing", |
112 | 143 | registry.db_name, |
113 | 144 | ) |
| 145 | + else: |
| 146 | + env = Environment(cr, SUPERUSER_ID, {}) |
| 147 | + |
| 148 | + # First check for endpoints with registry_sync=False (never synced) |
| 149 | + unsynced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", False)]) |
| 150 | + |
| 151 | + # Also check for endpoints that claim to be synced but have no routes |
| 152 | + # This catches cases where routes were deleted or DB was reset |
| 153 | + synced_endpoints = env["fastapi.endpoint"].search([("registry_sync", "=", True)]) |
| 154 | + if synced_endpoints and "endpoint.route" in env: |
| 155 | + for endpoint in synced_endpoints: |
| 156 | + route_exists = env["endpoint.route"].search_count( |
| 157 | + [("endpoint_id", "=", endpoint.id)], limit=1 |
| 158 | + ) |
| 159 | + if not route_exists: |
| 160 | + _logger.warning( |
| 161 | + "Endpoint '%s' (id=%d) claims to be synced but has no routes - forcing re-sync", |
| 162 | + endpoint.name, |
| 163 | + endpoint.id, |
| 164 | + ) |
| 165 | + # Reset flag to trigger re-sync |
| 166 | + endpoint.registry_sync = False |
| 167 | + unsynced_endpoints |= endpoint |
| 168 | + |
| 169 | + if unsynced_endpoints: |
| 170 | + unsynced_endpoints.action_sync_registry() |
| 171 | + _logger.info( |
| 172 | + "Synced %d FastAPI endpoints for database %s", |
| 173 | + len(unsynced_endpoints), |
| 174 | + registry.db_name, |
| 175 | + ) |
| 176 | + # cr.commit() ends the transaction and RELEASES the |
| 177 | + # advisory lock acquired above. Do not add any sync |
| 178 | + # work below this line — it would run unlocked and |
| 179 | + # re-introduce the SerializationFailure race this |
| 180 | + # patch exists to prevent. |
| 181 | + cr.commit() |
114 | 182 | except Exception as e: |
115 | 183 | # If endpoint model doesn't exist or sync fails, continue anyway |
116 | 184 | _logger.debug("Could not sync FastAPI endpoints: %s", e) |
|
0 commit comments