Skip to content

Commit d58d1de

Browse files
committed
Merge remote-tracking branch 'origin/19.0' into fix/941-program-config-ux-polish
# Conflicts: # spp_programs/__manifest__.py # spp_programs/tests/__init__.py
2 parents f5569b1 + 00f1e5a commit d58d1de

565 files changed

Lines changed: 40026 additions & 9961 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.

.ruff.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ exclude = ["setup/*", "fastapi/*", "base_user_role/*", "endpoint_route_handler/*
1919
[lint.per-file-ignores]
2020
"__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py
2121
"__manifest__.py" = ["B018", "E501"] # allow long lines and useless expression in manifests
22+
# Schema definitions hold SPDCI-spec-verbatim description strings; reflowing them
23+
# would hurt fidelity to the standard, so allow long lines in this file only.
24+
"spp_dci_compliance/schemas/spdci_schemas.py" = ["E501"]
2225

2326
[lint.isort]
2427
section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"]

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ faker
88
fastapi>=0.110.0
99
geojson
1010
httpx
11+
jsonschema
1112
jwcrypto
1213
jwcrypto>=1.5.6
13-
libhxl
1414
numpy>=1.22.2
1515
openpyxl
1616
parse-accept-language

scripts/coverage_single_module.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env bash
2+
# Run coverage for a single OpenSPP module against the docker stack.
3+
# Mirrors the CI recipe in .github/workflows/ci.yml so local numbers
4+
# match what gets reported in CI.
5+
#
6+
# Usage: ./scripts/coverage_single_module.sh <module_name>
7+
8+
set -e
9+
10+
MODULE_NAME="${1:?usage: $0 <module_name>}"
11+
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12+
cd "$PROJECT_ROOT"
13+
14+
DB_NAME="cov_${MODULE_NAME}_$$"
15+
COV_FILE="/tmp/.coverage.${MODULE_NAME}.$$"
16+
LOG_FILE="/tmp/openspp-test-logs/${MODULE_NAME}_coverage_$(date +%Y%m%d_%H%M%S).log"
17+
mkdir -p "$(dirname "$LOG_FILE")"
18+
19+
# Ensure DB container is up.
20+
if ! docker compose ps db --status running --quiet >/dev/null 2>&1; then
21+
docker compose up -d db >/dev/null 2>&1
22+
until docker compose exec -T db pg_isready -U odoo -d postgres >/dev/null 2>&1; do
23+
sleep 1
24+
done
25+
fi
26+
27+
# Fresh database per run.
28+
docker compose exec -T db psql -U odoo -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" >/dev/null 2>&1
29+
docker compose exec -T db psql -U odoo -d postgres -c "CREATE DATABASE $DB_NAME;" >/dev/null 2>&1
30+
31+
echo "Running coverage for $MODULE_NAME (log: $LOG_FILE)..."
32+
33+
set +e
34+
docker compose run --rm \
35+
-e DB_NAME="$DB_NAME" \
36+
-e COVERAGE_FILE="$COV_FILE" \
37+
--entrypoint "" \
38+
test \
39+
bash -c "
40+
coverage run --branch --source=/mnt/extra-addons/openspp/${MODULE_NAME} \
41+
/opt/odoo/odoo/odoo-bin \
42+
--addons-path=/opt/odoo/odoo/addons,/opt/odoo/odoo/odoo/addons,/mnt/extra-addons/openspp,/mnt/extra-addons/server-ux,/mnt/extra-addons/server-tools,/mnt/extra-addons/odoo-job-worker,/mnt/extra-addons/server-backend,/mnt/extra-addons/rest-framework,/mnt/extra-addons/muk-it \
43+
-d $DB_NAME \
44+
--db_host=db --db_port=5432 --db_user=odoo --db_password=odoo \
45+
--stop-after-init --no-http \
46+
--data-dir /tmp/odoo-data \
47+
-i ${MODULE_NAME} \
48+
--test-tags /${MODULE_NAME} \
49+
--log-level=test 2>&1 | tee /tmp/test_output_${MODULE_NAME}.log
50+
echo '===COVERAGE REPORT==='
51+
coverage report --skip-empty --skip-covered --omit='*/tests/*,*/migrations/*' || true
52+
echo '===COVERAGE TOTAL==='
53+
coverage report --omit='*/tests/*,*/migrations/*' | tail -2 || true
54+
" > "$LOG_FILE" 2>&1
55+
EXIT=$?
56+
set -e
57+
58+
docker compose exec -T db psql -U odoo -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" >/dev/null 2>&1
59+
60+
echo "=== $MODULE_NAME coverage ==="
61+
sed -n '/===COVERAGE TOTAL===/,$p' "$LOG_FILE" | tail -3
62+
echo "(full log: $LOG_FILE)"
63+
exit $EXIT

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

0 commit comments

Comments
 (0)