fix(registry): cron-repair stored membership status/is_ended that go stale with the clock - #418
fix(registry): cron-repair stored membership status/is_ended that go stale with the clock#418gonzalesedwin1123 wants to merge 3 commits into
Conversation
…ainst the clock status and is_ended on spp.group.membership are store=True computes that depend only on ended_date and compare it against now(), so a recompute fires on a write to ended_date but never when the clock crosses it. A departure recorded ahead of time (future-dated ended_date) stayed stored as active/is_ended=False indefinitely once the date passed — rosters, metrics, API search and downstream authorization gates kept treating the member as current. Add an hourly cron that searches (archived rows included) for rows whose stored values disagree with the clock and re-triggers both computes via modified(). Its first run self-heals rows already stale in existing databases, so no migration script is needed. Fixes #417
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 19.0 #418 +/- ##
==========================================
+ Coverage 72.24% 72.68% +0.43%
==========================================
Files 419 569 +150
Lines 29813 39217 +9404
==========================================
+ Hits 21539 28503 +6964
- Misses 8274 10714 +2440
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
- invalidate group metrics for repaired memberships: the recompute flushes through low-level SQL and bypasses the write() override, so the metric-invalidation funnel must be called explicitly - rename the cron entry point to _cron_recompute_ended_status so it is not RPC-callable, matching the repo's cron naming pattern - bound each run to batch_size (default 10000) rows per direction so a large first-run backlog cannot exceed the cron time limit; repaired rows drop out of the domains, so subsequent runs drain the remainder - index ended_date, which both sweep domains filter on - return the repaired recordset and strengthen the tests: raw-SQL column assertions, over-match guard on the no-op case, metric-funnel invalidation, batch-size behavior, archived rows keep active=False, cron interval asserted
|
Applied findings from an internal expert review (commit c327b32):
Full |
| "[spp.registry] Scheduled ended-status recompute for %d group membership(s)", | ||
| len(stale), | ||
| ) | ||
| if len(to_end) == batch_size or len(to_reactivate) == batch_size: |
There was a problem hiding this comment.
Backlog draining / fault isolation. When more than batch_size rows are stale (first run on an existing DB, or a cohort-wide ended_date crossing the clock in the same hour), each hourly run repairs at most 10k per direction in one transaction: a 200k backlog takes ~20 hours to drain, and one contended row (REPEATABLE READ serialization failure on any of the up-to-20k rows) rolls back the whole run — repeated failures will auto-deactivate the cron.
Odoo 19's cron progress API addresses both: loop in chunks and call self.env["ir.cron"]._commit_progress(len(chunk), remaining=...) — a partially-done job is rescheduled ASAP instead of waiting an hour, and each chunk commits independently. That would also make HISTORY's "first run self-heals" claim hold for large backlogs, and it replaces this backlog heuristic — which as written logs a false positive when a run drains exactly batch_size rows, and silently disables the LIMIT if a caller ever passes batch_size=0.
| "name": "OpenSPP Registry", | ||
| "category": "OpenSPP/Core", | ||
| "version": "19.0.2.1.4", | ||
| "version": "19.0.2.1.5", |
There was a problem hiding this comment.
This bump is stale against 19.0: HEAD already ships spp_registry 19.0.2.2.2 (HISTORY has 2.2.1 and 2.2.2 entries newer than this branch's merge-base), so 19.0.2.1.5 is a version regression, and the regenerated README.rst/index.html on this branch drop main's 2.2.x changelog entries. Needs a rebase, renumber to 19.0.2.2.3, the HISTORY fragment repositioned above 2.2.2, and README regeneration on the rebased base.
| ("ended_date", "=", False), | ||
| ("ended_date", ">", now), | ||
| "|", | ||
| ("is_ended", "=", True), |
There was a problem hiding this comment.
("is_ended", "=", True) compiles to is_ended IS TRUE, which excludes NULL — so a row whose is_ended is NULL (raw INSERT/ETL that omitted the column; it is nullable with no SQL default) is never repaired by this direction, while the to_end direction does catch NULL (= False compiles to IS NOT TRUE). NULL misbehaves exactly like the drift this sweep exists for: the raw-SQL consumers (NOT is_ended / is_ended = false) treat NULL rows as ended. Worth either a NULL-repair leg here, or making the column NOT NULL DEFAULT false so the state cannot exist.
| ], | ||
| limit=batch_size, | ||
| ) | ||
| to_reactivate = memberships.search( |
There was a problem hiding this comment.
Steady-state cost: this leg's date predicate (ended_date IS NULL OR > now) matches nearly every live row and the discriminating columns are unindexed, so each hourly run does a full-table read (EXPLAIN on a 1M-row mirror: backward pkey scan, ~80MB — and the same holds for the to_end direction once a registry has many historical departures; the new ended_date index cannot serve either domain as written because of the OR structure). Two cheap options: split each direction into per-leg conjunctive searches so (partial) indexes can serve them, and/or run this reactivate leg — which only guards non-ORM drift — daily rather than hourly.
|
|
||
| start_date = fields.Datetime(default=lambda self: fields.Datetime.now()) | ||
| ended_date = fields.Datetime() | ||
| ended_date = fields.Datetime(index=True) |
There was a problem hiding this comment.
index=True btree-indexes every NULL ended_date (the open-membership majority). btree_not_null skips the NULLs, serves every real query on this column at least as well (<= now here; != False AND >= since in group_service), and avoids the NULL-entry write amplification on every membership insert.
| ended_date = fields.Datetime(index=True) | |
| ended_date = fields.Datetime(index="btree_not_null") |
|
|
||
| # An over-matching domain would sweep these rows in; they must not | ||
| # be selected at all, not merely end up with unchanged values. | ||
| self.assertFalse(repaired) |
There was a problem hiding this comment.
These assertions run against the whole spp_group_membership table (the cron searches unscoped with active_test=False): assertFalse(repaired) here, assertEqual(repaired, rec) at L63/L89, the exact 2/1 counts at L154-157, and funnel.assert_called_once() at L292. That is latent flakiness: any committed install-time/demo row whose ended_date crosses now between install and the post_install run breaks them (none exists today, but ci-full runs these tests on demo-seeded DBs). Scoped shapes preserve the over-match intent: assertIn(rec, repaired) at 63/89, and here self.assertFalse(repaired & (open_ended | already_ended)).
| ("ended_date", "<=", now), | ||
| "|", | ||
| ("is_ended", "=", False), | ||
| ("status", "!=", "inactive"), |
There was a problem hiding this comment.
status and is_ended are two stored encodings of the same predicate, which is why every domain here needs a second OR-leg — and the pair genuinely can disagree: the two computes each call fields.Datetime.now() at different instants, so a write in that sub-second window can store an inconsistent pair (which these two-leg domains then self-heal, so the leg should not simply be dropped as-is). The deeper fix, fine as a follow-up: make _compute_status @api.depends("is_ended") and derive "inactive" if rec.is_ended else "active" — the pair can then never disagree, the race disappears, and these domains shrink to their is_ended leg (which a partial index can serve).
| """ | ||
| now = fields.Datetime.now() | ||
| memberships = self.with_context(active_test=False) | ||
| to_end = memberships.search( |
There was a problem hiding this comment.
These two domains are the 4th and 5th in-model spelling of the "ended at time T" predicate (_compute_is_ended, _compute_status, _onchange_ended_date), and external consumers already re-roll it in three inconsistent variants. A small model-level helper (_is_ended_at(now) plus _ended_domain(now)/_not_ended_domain(now)) used by the computes, the onchange and these searches would give #421 (start_date semantics) one place to change instead of five.
| <field name="model_id" ref="model_spp_group_membership" /> | ||
| <field name="state">code</field> | ||
| <field name="code">model._cron_recompute_ended_status()</field> | ||
| <field name="interval_number">1</field> |
There was a problem hiding this comment.
Non-blocking refinement: the exact transition time is known when ended_date is written, and Odoo 19 crons can be pointed at it — self.env.ref("spp_registry.cron_recompute_membership_ended_status")._trigger(at=ended_date) from create/write when a future end is set (~6 lines; triggers persist across restarts, and a stale trigger just runs the idempotent sweep). That shrinks the staleness window from up to an hour to about a minute and would let this periodic sweep drop to a daily safety net.
|
|
||
| # The recompute flushes through low-level SQL and bypasses write(), | ||
| # so the cron must call the metric-invalidation funnel itself. | ||
| with patch.object( |
There was a problem hiding this comment.
test_metric_invalidation.py already ships this exact patch as _patch_invalidate_funnel(env) (L37-48) — importing it (this file already imports from a sibling test module) drops the hand-rolled patch.object and the unittest.mock import.
| vals.update({"group": self.group.id, "individual": individual.id}) | ||
| return self.Membership.create(vals) | ||
|
|
||
| def _age_row(self, rec, start_date, ended_date, active=True): |
There was a problem hiding this comment.
Nothing these tests exercise reads start_date (both computes, both cron domains and the raw-SQL readers key off ended_date; the start/end constraint is ORM-only and bypassed by the raw UPDATE), yet it is a required positional — so four call sites repeat now - timedelta(days=730), now - timedelta(days=365) plus a throwaway now local, and L83 re-passes the value the row already has. _age_row(rec, ended_date=None, active=True) defaulting to a year ago (deriving start_date = ended_date - timedelta(days=365) inside, to keep the row consistent) collapses the call sites to _age_row(rec) / _age_row(rec, future) / _age_row(rec, active=False).
| stale = to_end | to_reactivate | ||
| if stale: | ||
| stale.modified(["ended_date"]) | ||
| # The recompute flushes through low-level SQL and bypasses this |
There was a problem hiding this comment.
Worth extending this note with the two write-path side effects that do differ: the flush still stamps write_uid/write_date (repaired rows show the cron user as last-modified — e.g. changed_by in the API's membership history reads membership.write_uid), and the repair is invisible to spp_audit write-rules (they hook write(), so a UI write of ended_date is logged but the cron's status flip is not). Both are acceptable — just worth documenting here.
| record.status = "active" | ||
|
|
||
| @api.model | ||
| def _cron_recompute_ended_status(self, batch_size=10000): |
There was a problem hiding this comment.
The performance principles doc referenced from AGENTS.md caps batch processing at 5,000 records per chunk; this defaults to 10,000 per direction (up to 20k rows marked in one transaction). If that principle is meant to govern crons, batch_size=5000 is the one-token fix (the cron XML passes no argument and the tests pin their own values); if not, feel free to ignore.
| @@ -0,0 +1,16 @@ | |||
| <?xml version="1.0" encoding="utf-8" ?> | |||
| <odoo noupdate="1"> | |||
| <!-- status/is_ended are stored computes over ended_date compared against | |||
There was a problem hiding this comment.
This paragraph now exists in four places (here, the method docstring, HISTORY.md, and the test module docstring) — and #421 is queued to change exactly these semantics. Suggest keeping the method docstring as the one home and shrinking this comment (and the test docstring's overlap) to a pointer, e.g. Repairs stored status/is_ended once the clock passes ended_date; see _cron_recompute_ended_status (#417).
| now(); nothing recomputes them when the clock crosses the date, so a | ||
| future-dated departure would stay stored as active forever without | ||
| this hourly repair pass (issue #417). --> | ||
| <record id="cron_recompute_membership_ended_status" model="ir.cron"> |
There was a problem hiding this comment.
Hardening nit: with no user_id, the cron runs as the data-load default (OdooBot, superuser) — which is what makes the unsudo'd searches immune to the two global disabled-registrant ir.rules on this model. If an operator ever reassigns the Scheduler User to a non-superuser, memberships of disabled registrants silently stop being repaired. Pinning <field name="user_id" ref="base.user_root"/> (as the spp_dci crons do) makes the assumption explicit.
kneckinator
left a comment
There was a problem hiding this comment.
Thanks for the PR @gonzalesedwin1123
It is currently in conflict with 19.0 - please merge/rebase.
I left a couple of comments - please take a look. 🙏
Fixes #417.
Problem
spp.group.membership.statusandis_endedarestore=Truecomputes that depend only onended_dateand compare it againstfields.Datetime.now(). A recompute fires on a write toended_date, never when the clock crosses it — so a departure recorded ahead of time (a future-datedended_date) stays stored asactive/is_ended = Falseindefinitely once the date passes. Every consumer inherits the staleness: rosters, metrics, API search, and downstream gates keep treating the departed member as current. See #417 for the full consumer inventory.Fix
Option (1) from the issue, as ranked there: keep the fields stored and add an hourly
ir.croninspp_registry(_cron_recompute_ended_status, private so it is not RPC-callable) that finds rows whose stored values disagree with the clock and re-triggers both computes through the normal ORM path (modified(["ended_date"])).is_endedcolumn.active_test=Falseso memberships archived by the UI onchange are repaired too.batch_size(default 10,000) rows per direction per run, so a large first-run backlog cannot exceed the cron time limit; repaired rows drop out of the domains, so subsequent hourly runs drain the remainder (with a log line when a backlog remains).ended_date, which both sweep domains filter on, is now indexed.write()override, so the metric-invalidation funnel would otherwise never fire (only the two target computes depend onended_datein ORM terms, but that hook is a manual, non-ORM dependency).ended_dateindex is likewise created automatically on upgrade).Out of scope (per issue discussion)
activehas a related inconsistency: the UI onchange archives a membership when a pastended_dateis entered, but nothing archives it when the clock crosses a future one. Left deliberately untouched (and documented in the cron's docstring) — archiving changes record visibility everywhere. Tracked in spp.group.membership: archiving viaactiveis inconsistent — set only by a UI onchange, never when the clock crosses ended_date #420.start_dateis ignored by both computes (a membership starting in 2099 is "active" today) — a semantics change affecting ~30 consumers, better handled separately. Tracked in spp.group.membership:status/is_endedignore start_date — a membership starting in the future counts as active today #421.Testing
TDD: the new
TestMembershipEndedStatusCron(7 tests) reproduces the production state per the issue's recipe — aging rows behind the ORM's back with raw SQL — and each round was confirmed red before its implementation. Coverage includes raw-SQL column assertions (the raw-SQLis_endedconsumers never see the ORM cache), an over-match guard asserting the no-op case selects zero rows, metric-funnel invalidation, batch-size behavior, archived rows keepingactive = False, and the registered cron's interval. Fullspp_registrysuite: 252 passed, 0 failed, 0 errors. Pre-commit hooks pass on the changed files.Note:
README.rst/index.htmlregeneration is taken verbatim from CI's pinned generator (already applied), not generated locally.