Skip to content

Commit 9b9e857

Browse files
committed
Merge branch '19.0' into fix/hide-pager-on-single-record
2 parents b013691 + b3510d8 commit 9b9e857

174 files changed

Lines changed: 5200 additions & 293 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

scripts/detect_modules.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def main():
151151
# Build dependency graph
152152
reverse_deps = build_reverse_dependency_graph(project_root, all_modules)
153153

154+
changed_modules: set[str] = set()
154155
if args.all:
155156
# List all modules with tests (no limit for --all)
156157
modules_to_test = {m for m in all_modules if has_tests(project_root / m)}
@@ -184,10 +185,17 @@ def main():
184185

185186
# Apply max limit if set
186187
if args.max_modules > 0 and len(sorted_modules) > args.max_modules:
187-
# Prioritize critical modules
188-
critical_first = [m for m in sorted_modules if m in CRITICAL_MODULES]
189-
others = [m for m in sorted_modules if m not in CRITICAL_MODULES]
190-
sorted_modules = (critical_first + others)[: args.max_modules]
188+
# Priority order when the matrix is over-subscribed:
189+
# 1. Critical modules (always tested).
190+
# 2. Directly-changed modules (a patch must run its own tests so
191+
# codecov/patch reflects the new coverage — without this, the
192+
# changed module can fall off the alpha tail and codecov
193+
# carryforward leaves the old figures in place).
194+
# 3. Transitively-impacted modules, alphabetical.
195+
critical = [m for m in sorted_modules if m in CRITICAL_MODULES]
196+
directly_changed = [m for m in sorted_modules if m in changed_modules and m not in CRITICAL_MODULES]
197+
others = [m for m in sorted_modules if m not in CRITICAL_MODULES and m not in changed_modules]
198+
sorted_modules = (critical + directly_changed + others)[: args.max_modules]
191199
print(
192200
f"Warning: Limited to {args.max_modules} modules",
193201
file=sys.stderr,

spp

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,36 @@ def _find_running_odoo() -> tuple:
284284
return None, None
285285

286286

287+
def _is_jobworker_running(profile: str) -> bool:
288+
"""Return True when the jobworker container is up under this profile.
289+
290+
`./spp restart` only restarts the Odoo service by default, but module
291+
code that runs inside delayed jobs (async batch scoring, eligibility
292+
checks, anything routed through job_worker.delay) lives in the
293+
jobworker process. Without restarting the worker too, the developer
294+
sees stale Python after Apps -> Upgrade. See OP#986.
295+
"""
296+
result = run(
297+
docker_compose("ps", "--format", "json", profile=profile),
298+
capture=True,
299+
check=False,
300+
)
301+
if result.returncode != 0:
302+
return False
303+
304+
for line in result.stdout.strip().split("\n"):
305+
if not line:
306+
continue
307+
try:
308+
container = json.loads(line)
309+
if container.get("Service") == "jobworker" and container.get("State") == "running":
310+
return True
311+
except json.JSONDecodeError:
312+
continue
313+
314+
return False
315+
316+
287317
# ============================================================================
288318
# Commands
289319
# ============================================================================
@@ -697,8 +727,12 @@ def cmd_restart(args):
697727
print("Error: No Odoo container running. Start with './spp start' first.")
698728
sys.exit(1)
699729

700-
print(f"Restarting {service}...")
701-
run(docker_compose("restart", service, profile=profile))
730+
services_to_restart = [service]
731+
if _is_jobworker_running(profile):
732+
services_to_restart.append("jobworker")
733+
734+
print(f"Restarting {', '.join(services_to_restart)}...")
735+
run(docker_compose("restart", *services_to_restart, profile=profile))
702736
print("Done.")
703737

704738
_show_url()

spp_api_v2/README.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@ Dependencies
147147
Changelog
148148
=========
149149

150+
19.0.2.0.1
151+
~~~~~~~~~~
152+
153+
- Fix ``SerializationFailure`` race when multiple Odoo workers rebuild
154+
their routing map simultaneously (e.g. after ``-u all``) and all try
155+
to sync the same ``fastapi.endpoint`` rows
156+
- Serialize concurrent sync attempts across workers using a
157+
transaction-scoped Postgres advisory lock; workers that don't acquire
158+
the lock skip the sync and pick up the freshly synced routes on the
159+
next routing-map rebuild (via ``endpoint_route_version`` cache
160+
invalidation)
161+
- Log skipped syncs at INFO and lock-primitive failures at WARNING so
162+
cold-start route-availability symptoms and broken-primitive
163+
regressions are diagnosable without raising the global log level
164+
150165
19.0.2.0.0
151166
~~~~~~~~~~
152167

spp_api_v2/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "OpenSPP API V2",
33
"category": "OpenSPP/Integration",
4-
"version": "19.0.2.0.0",
4+
"version": "19.0.2.0.1",
55
"sequence": 1,
66
"author": "OpenSPP.org",
77
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_api_v2/models/ir_http_patch.py

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
This workaround should be removed when Odoo core fixes the cache bug.
99
"""
1010

11+
import hashlib
1112
import logging
1213
import threading
1314

@@ -22,6 +23,46 @@
2223

2324
_logger = logging.getLogger(__name__)
2425

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+
2566

2667
class IrHttp(models.AbstractModel):
2768
"""Patch ir.http to fix routing_map cache bug"""
@@ -80,37 +121,64 @@ def routing_map(self, key=None):
80121
from odoo.api import Environment
81122

82123
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.
109141
_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",
112143
registry.db_name,
113144
)
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()
114182
except Exception as e:
115183
# If endpoint model doesn't exist or sync fails, continue anyway
116184
_logger.debug("Could not sync FastAPI endpoints: %s", e)

spp_api_v2/readme/HISTORY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### 19.0.2.0.1
2+
3+
- Fix `SerializationFailure` race when multiple Odoo workers rebuild their routing map simultaneously (e.g. after `-u all`) and all try to sync the same `fastapi.endpoint` rows
4+
- Serialize concurrent sync attempts across workers using a transaction-scoped Postgres advisory lock; workers that don't acquire the lock skip the sync and pick up the freshly synced routes on the next routing-map rebuild (via `endpoint_route_version` cache invalidation)
5+
- Log skipped syncs at INFO and lock-primitive failures at WARNING so cold-start route-availability symptoms and broken-primitive regressions are diagnosable without raising the global log level
6+
17
### 19.0.2.0.0
28

39
- Initial migration to OpenSPP2

spp_api_v2/static/description/index.html

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -517,32 +517,49 @@ <h1>Dependencies</h1>
517517
<div class="contents local topic" id="contents">
518518
<ul class="simple">
519519
<li><a class="reference internal" href="#changelog" id="toc-entry-1">Changelog</a><ul>
520-
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.0.0</a></li>
520+
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.2.0.1</a></li>
521+
<li><a class="reference internal" href="#section-2" id="toc-entry-3">19.0.2.0.0</a></li>
521522
</ul>
522523
</li>
523-
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-3">Bug Tracker</a></li>
524-
<li><a class="reference internal" href="#credits" id="toc-entry-4">Credits</a></li>
524+
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
525+
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a></li>
525526
</ul>
526527
</div>
527528
<div class="section" id="changelog">
528529
<h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
529530
<div class="section" id="section-1">
530-
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.0</a></h3>
531+
<h3><a class="toc-backref" href="#toc-entry-2">19.0.2.0.1</a></h3>
532+
<ul class="simple">
533+
<li>Fix <tt class="docutils literal">SerializationFailure</tt> race when multiple Odoo workers rebuild
534+
their routing map simultaneously (e.g. after <tt class="docutils literal"><span class="pre">-u</span> all</tt>) and all try
535+
to sync the same <tt class="docutils literal">fastapi.endpoint</tt> rows</li>
536+
<li>Serialize concurrent sync attempts across workers using a
537+
transaction-scoped Postgres advisory lock; workers that don’t acquire
538+
the lock skip the sync and pick up the freshly synced routes on the
539+
next routing-map rebuild (via <tt class="docutils literal">endpoint_route_version</tt> cache
540+
invalidation)</li>
541+
<li>Log skipped syncs at INFO and lock-primitive failures at WARNING so
542+
cold-start route-availability symptoms and broken-primitive
543+
regressions are diagnosable without raising the global log level</li>
544+
</ul>
545+
</div>
546+
<div class="section" id="section-2">
547+
<h3><a class="toc-backref" href="#toc-entry-3">19.0.2.0.0</a></h3>
531548
<ul class="simple">
532549
<li>Initial migration to OpenSPP2</li>
533550
</ul>
534551
</div>
535552
</div>
536553
<div class="section" id="bug-tracker">
537-
<h2><a class="toc-backref" href="#toc-entry-3">Bug Tracker</a></h2>
554+
<h2><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h2>
538555
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OpenSPP/OpenSPP2/issues">GitHub Issues</a>.
539556
In case of trouble, please check there if your issue has already been reported.
540557
If you spotted it first, help us to smash it by providing a detailed and welcomed
541558
<a class="reference external" href="https://github.com/OpenSPP/OpenSPP2/issues/new?body=module:%20spp_api_v2%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
542559
<p>Do not contact contributors directly about support or help with technical issues.</p>
543560
</div>
544561
<div class="section" id="credits">
545-
<h2><a class="toc-backref" href="#toc-entry-4">Credits</a></h2>
562+
<h2><a class="toc-backref" href="#toc-entry-5">Credits</a></h2>
546563
</div>
547564
</div>
548565
<div class="section" id="authors">

spp_api_v2/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from . import test_group_service
2525
from . import test_individual_api
2626
from . import test_individual_service
27+
from . import test_ir_http_patch
2728
from . import test_jwt_secret_validation
2829
from . import test_metadata
2930
from . import test_oauth

0 commit comments

Comments
 (0)