diff --git a/spp_registry/README.rst b/spp_registry/README.rst
index 1ead9f1ac..da0eeb46a 100644
--- a/spp_registry/README.rst
+++ b/spp_registry/README.rst
@@ -139,6 +139,29 @@ Dependencies
Changelog
=========
+19.0.2.2.3
+~~~~~~~~~~
+
+- fix(registry): repair the stored ``status``/``is_ended`` computes on
+ ``spp.group.membership`` once the clock crosses ``ended_date``. Both
+ fields depend only on ``ended_date`` compared against *now*, so a
+ future-dated departure never took effect once the clock crossed it —
+ rosters, metrics, API search and downstream gates kept treating the
+ member as active indefinitely. Writing a future ``ended_date`` now
+ schedules a lightweight, index-served repair cron in the minute after
+ that moment (staleness window ≈1–2 minutes), and a daily sweep
+ self-heals everything else: rows already stale in existing databases
+ (drained in committed batches, resuming across runs until the backlog
+ is gone) and rows written behind the ORM, including
+ ``is_ended = NULL`` rows that raw-SQL consumers treated as ended
+ (#417)
+- upgrade note: this version adds a partial index on
+ ``spp_group_membership.ended_date``, built with a write-blocking
+ ``CREATE INDEX`` during the module upgrade. On a very large registry,
+ pre-create it concurrently before upgrading and the upgrade will skip
+ the build:
+ ``CREATE INDEX CONCURRENTLY IF NOT EXISTS spp_group_membership__ended_date_index ON spp_group_membership (ended_date) WHERE ended_date IS NOT NULL;``
+
19.0.2.2.2
~~~~~~~~~~
diff --git a/spp_registry/__manifest__.py b/spp_registry/__manifest__.py
index 81e898bf8..c8e43552f 100644
--- a/spp_registry/__manifest__.py
+++ b/spp_registry/__manifest__.py
@@ -3,7 +3,7 @@
{
"name": "OpenSPP Registry",
"category": "OpenSPP/Core",
- "version": "19.0.2.2.2",
+ "version": "19.0.2.2.3",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
@@ -33,6 +33,7 @@
# "data/id_types.xml",
"data/vocabularies.xml",
"data/res_users.xml",
+ "data/ir_cron.xml",
# Security
"security/privileges.xml",
"security/groups.xml",
diff --git a/spp_registry/data/ir_cron.xml b/spp_registry/data/ir_cron.xml
new file mode 100644
index 000000000..5490e21f9
--- /dev/null
+++ b/spp_registry/data/ir_cron.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ Registry: Recompute Ended Group Memberships
+
+ code
+ model._cron_recompute_ended_status()
+ 1
+ days
+
+
+
+
+
+
+
+
+ Registry: Repair Clock-Crossed Group Memberships
+
+ code
+ model._cron_repair_crossed_ended_status()
+ 1
+ days
+
+
+
+
+
diff --git a/spp_registry/models/group_membership.py b/spp_registry/models/group_membership.py
index 467170fd6..0423a0557 100644
--- a/spp_registry/models/group_membership.py
+++ b/spp_registry/models/group_membership.py
@@ -1,6 +1,7 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
import logging
+from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
@@ -51,7 +52,9 @@ def _compute_has_group_membership_type_codes(self):
rec.has_group_membership_type_codes = has_codes
start_date = fields.Datetime(default=lambda self: fields.Datetime.now())
- ended_date = fields.Datetime()
+ # btree_not_null: a plain btree would index every NULL ended_date (the
+ # open-membership majority) for no query benefit, amplifying writes.
+ ended_date = fields.Datetime(index="btree_not_null")
status = fields.Selection(
[("inactive", "Inactive"), ("active", " ")],
compute="_compute_status",
@@ -122,14 +125,22 @@ def _name_search(self, name, domain=None, operator="ilike", limit=100, order=Non
domain = [("group", operator, name)] + domain
return self._search(domain, limit=limit, order=order)
+ @api.model
+ def _is_ended_as_of(self, ended_date, now):
+ """Single home of the "membership is ended at time T" predicate.
+
+ Used by both stored computes, the archiving onchange and the
+ repair-cron domains; keep external consumers (raw-SQL readers of
+ ``is_ended`` in spp_registry/models/group.py, spp_api_v2_gis,
+ spp_cel_domain, spp_gis_report) in mind when changing it (#421).
+ """
+ return bool(ended_date and ended_date <= now)
+
@api.depends("ended_date")
def _compute_is_ended(self):
+ now = fields.Datetime.now()
for rec in self:
- is_ended = False
- if rec.ended_date and rec.ended_date <= fields.Datetime.now():
- is_ended = True
-
- rec.is_ended = is_ended
+ rec.is_ended = self._is_ended_as_of(rec.ended_date, now)
def _invalidate_group_metrics(self, groups):
"""Schedule metric invalidation for affected groups.
@@ -159,6 +170,8 @@ def write(self, vals):
affected_groups |= self.mapped("group")
self._invalidate_group_metrics(affected_groups)
+ if self and "ended_date" in vals:
+ self._schedule_ended_status_repair([vals["ended_date"]])
return res
@api.model_create_multi
@@ -167,6 +180,9 @@ def create(self, vals_list):
# Invalidate metrics for all affected groups
groups = res.mapped("group")
self._invalidate_group_metrics(groups)
+ # Read the dates back from the records, not vals_list: a missing
+ # key can still be filled from a default_ended_date context key.
+ self._schedule_ended_status_repair(res.mapped("ended_date"))
return res
def unlink(self):
@@ -204,12 +220,272 @@ def open_group_form(self):
@api.depends("ended_date")
def _compute_status(self):
+ now = fields.Datetime.now()
for record in self:
- # check if memebership end date available and less than current date
- if record.ended_date and record.ended_date <= fields.Datetime.now():
- record.status = "inactive"
- else:
- record.status = "active"
+ # check if membership end date available and less than current date
+ record.status = "inactive" if self._is_ended_as_of(record.ended_date, now) else "active"
+
+ def _schedule_ended_status_repair(self, ended_dates):
+ """Point the repair cron at every future ``ended_date`` being written.
+
+ The stored computes go stale the moment the clock crosses
+ ``ended_date`` (see ``_cron_recompute_ended_status``); a persistent
+ ``ir.cron.trigger`` at that time shrinks the staleness window from
+ the sweep cadence to a couple of minutes. Each moment is rounded up
+ to the next full minute *past* ``ended_date`` — unconditionally,
+ because the cron machinery consumes triggers against the database
+ clock while the repair predicate compares against the application
+ clock, so a trigger firing exactly at ``ended_date`` could be
+ consumed while the row still reads as not-yet-ended. Triggers
+ already pending for the same minute are reused, so a cohort
+ departure written one row per call files a single trigger; two
+ concurrent transactions can still race to a duplicate, and a
+ trigger orphaned by a later date change survives to its moment —
+ both harmless, each just runs one idempotent index-served sweep.
+ """
+ now = fields.Datetime.now()
+ at_list = set()
+ for ended in ended_dates:
+ ended = fields.Datetime.to_datetime(ended)
+ if not ended or ended <= now:
+ continue
+ at_list.add(ended.replace(second=0, microsecond=0) + timedelta(minutes=1))
+ if not at_list:
+ return
+ # raise_if_not_found=False: if the noupdate cron record has been
+ # deleted, membership writes must degrade to the daily sweep, not
+ # break the core registry write path over a latency optimisation.
+ cron = self.env.ref(
+ "spp_registry.cron_repair_crossed_membership_ended_status",
+ raise_if_not_found=False,
+ )
+ if not cron:
+ return
+ pending = (
+ self.env["ir.cron.trigger"] # nosemgrep: odoo-sudo-without-context
+ .sudo() # system table ordinary users cannot read; _trigger creates as sudo anyway
+ .search([("cron_id", "=", cron.id), ("call_at", "in", list(at_list))])
+ )
+ at_list -= set(pending.mapped("call_at"))
+ if at_list:
+ cron._trigger(at=at_list)
+
+ @api.model
+ def _crossed_ended_status_domains(self, now):
+ """Domains selecting rows the clock has crossed: ``ended_date`` is
+ past but a stored column still reads active. One conjunctive leg
+ per stale column — no ORs, so both legs are served by the partial
+ ``ended_date`` index. These are the only stale states the mere
+ passage of time can produce, so the trigger-driven cron sweeps
+ just these.
+ """
+ return [
+ [("ended_date", "<=", now), ("is_ended", "=", False)],
+ [("ended_date", "<=", now), ("status", "!=", "inactive")],
+ ]
+
+ @api.model
+ def _stale_ended_status_domains(self, now):
+ """All domains selecting rows whose stored ``status``/``is_ended``
+ disagree with the clock. Beyond the crossed legs this adds the
+ reactivation legs, which only guard rows whose ``ended_date`` was
+ cleared or future-moved behind the ORM and are expected to match
+ nothing. The two ``ended_date IS NULL`` legs cannot be served by
+ the partial index (each probe is a full table scan), which is why
+ only the daily safety net runs them — never the trigger-driven
+ cron. If a registry ever accumulates enough non-ORM drift for
+ those scans to matter, give them partial indexes or a last-swept
+ watermark (see #421 for the planned collapse of the status legs).
+ """
+ return self._crossed_ended_status_domains(now) + [
+ # Not (or no longer) ended, still stored as ended.
+ [("ended_date", "=", False), ("is_ended", "=", True)],
+ [("ended_date", "=", False), ("status", "!=", "active")],
+ [("ended_date", ">", now), ("is_ended", "=", True)],
+ [("ended_date", ">", now), ("status", "!=", "active")],
+ ]
+
+ @api.model
+ def _repair_null_is_ended(self, now, batch_size):
+ """Repair rows holding ``is_ended = NULL`` that should read active.
+
+ Rows written behind the ORM can leave ``is_ended`` NULL, and every
+ raw-SQL consumer of the column treats NULL as ended. The ORM
+ cannot repair the open-membership case — NULL reads back from the
+ cache as False, so a recompute writes nothing — hence this one
+ SQL-level leg mirroring ``_is_ended_as_of``. (NULL rows whose
+ ``ended_date`` has passed need no special casing: the computed
+ True differs from the cached False, so the ORM legs repair them.)
+
+ Expected to match nothing on a healthy database (the column has a
+ Python default, so it was backfilled at creation) — but the
+ ``is_ended IS NULL`` probe has no index behind it, so even proving
+ that costs a full table scan, which is why only the daily safety
+ net calls this, never the trigger-driven cron. The UPDATE stamps
+ ``write_date``/``write_uid`` the way the ORM legs' flush does, so
+ ``write_date``-keyed consumers (incremental syncs, the API's
+ ``changed_by``) see the repair. Work is bounded to ``batch_size``
+ rows per statement, with progress committed between batches.
+
+ Returns ``(repaired, time_left)``: the repaired memberships and
+ the cron time budget reported by the last progress commit — falsy
+ when the budget ran out with NULL rows possibly left.
+ """
+ self.flush_model(["is_ended", "ended_date"])
+ repaired = self.browse()
+ time_left = float("inf")
+ while True:
+ self.env.cr.execute(
+ "UPDATE spp_group_membership "
+ "SET is_ended = false, write_date = %s, write_uid = %s "
+ "WHERE id IN (SELECT id FROM spp_group_membership "
+ "WHERE is_ended IS NULL AND (ended_date IS NULL OR ended_date > %s) LIMIT %s) "
+ "RETURNING id",
+ (now, self.env.uid, now, batch_size),
+ )
+ ids = [row[0] for row in self.env.cr.fetchall()]
+ if not ids:
+ break
+ batch = self.browse(ids)
+ batch.invalidate_recordset()
+ self._invalidate_group_metrics(batch.mapped("group"))
+ repaired |= batch
+ if len(ids) < batch_size:
+ self.env["ir.cron"]._commit_progress(len(ids), remaining=0)
+ break
+ # A full batch may not be the last: report the backlog so a
+ # budget-exhausted run is continued ASAP instead of waiting a
+ # full sweep interval on a "no work remaining" report.
+ time_left = self.env["ir.cron"]._commit_progress(len(ids), remaining=1)
+ if not time_left:
+ break
+ return repaired, time_left
+
+ @api.model
+ def _cron_repair_crossed_ended_status(self, batch_size=5000):
+ """Trigger-driven repair: only the index-served crossed legs.
+
+ ``_schedule_ended_status_repair`` points this cron at every future
+ ``ended_date`` written, so it can run many times a day and must
+ stay cheap — no NULL-drift legs (each of those probes is a full
+ table scan; the daily ``_cron_recompute_ended_status`` sweeps
+ them). See that method for the full story and the shared
+ semantics.
+ """
+ return self._repair_stale_ended_status(batch_size, full=False)
+
+ @api.model
+ def _cron_recompute_ended_status(self, batch_size=5000):
+ """Daily safety net: repair stored ``status``/``is_ended`` on every
+ row whose stored values disagree with the clock.
+
+ Both computes depend only on ``ended_date`` and compare it against
+ *now*, so a recompute fires on a write to ``ended_date`` but never
+ when time passes it: a future-dated departure would stay stored as
+ active forever. Writes of a future ``ended_date`` schedule the
+ lightweight ``_cron_repair_crossed_ended_status`` in the minute
+ after that moment (``_schedule_ended_status_repair``), so this
+ daily sweep self-heals what triggers cannot cover: rows already
+ stale in pre-existing databases and rows written behind the ORM,
+ including the full-scan NULL-drift legs and
+ ``_repair_null_is_ended``. It finds stale rows and re-triggers the
+ computes through the normal ORM path. Archived rows are included —
+ the UI onchange archives memberships ended in the past, and those
+ must be repaired too. ``active`` itself is deliberately left
+ untouched: archiving changes record visibility everywhere and is a
+ separate decision from the stored computes (see issue #417).
+
+ Two write-path side effects intentionally differ from a real
+ write: the recompute 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 ``write_uid``;
+ the raw NULL-repair UPDATE stamps the same columns itself), and
+ the repair is invisible to ``spp_audit`` write-rules, which hook
+ ``write()``. Both are accepted: the repair only restores what a
+ timely recompute would have stored.
+
+ Rows are repaired in ``batch_size`` chunks, each committed via
+ ``ir.cron._commit_progress``: a serialization failure rolls back
+ only its own chunk, and a backlog larger than the cron time budget
+ drains across runs — a partially-done run is rescheduled ASAP
+ instead of waiting a full sweep interval. ``_commit_progress`` is
+ Odoo 19's sanctioned batching API for cron work and the deliberate
+ exception to the AGENTS.md checklist's "no ``cr.commit()`` in
+ loops" rule, which targets ad-hoc commits. Because of those
+ commits, calling this outside a cron (e.g. from a shell) commits
+ the current transaction. The chunk size follows the 5,000-record
+ cap in docs/principles/performance-scalability.md.
+
+ Returns the repaired memberships.
+ """
+ return self._repair_stale_ended_status(batch_size, full=True)
+
+ @api.model
+ def _repair_stale_ended_status(self, batch_size, full):
+ """Shared repair loop; see ``_cron_recompute_ended_status``."""
+ if batch_size < 1:
+ raise ValueError("batch_size must be a positive number of rows")
+ memberships = self.with_context(active_test=False)
+ repaired_ids = set()
+ time_left = float("inf")
+ if full:
+ null_repaired, time_left = memberships._repair_null_is_ended(fields.Datetime.now(), batch_size)
+ repaired_ids.update(null_repaired.ids)
+ domains = self._stale_ended_status_domains if full else self._crossed_ended_status_domains
+ pass_no = 0
+ while time_left:
+ # Re-read the clock every pass: a row whose ended_date is
+ # crossed while the run is in flight recomputes to the very
+ # values a stale `now` would keep selecting it for.
+ now = fields.Datetime.now()
+ legs = domains(now)
+ # Rotate the starting leg each pass so one direction's large
+ # backlog cannot starve the other directions within a run.
+ offset = pass_no % len(legs)
+ pass_no += 1
+ chunk = memberships.browse()
+ exhausted = True
+ for leg in legs[offset:] + legs[:offset]:
+ quota = batch_size - len(chunk)
+ if quota <= 0:
+ exhausted = False
+ break
+ found = memberships.search(leg, limit=quota)
+ if len(found) == quota:
+ # The leg may hold more. Exhaustion must be tracked
+ # per leg: the legs overlap and the union
+ # de-duplicates, so a short chunk alone is no proof
+ # the backlog is drained.
+ exhausted = False
+ chunk |= found
+ if not chunk:
+ if repaired_ids:
+ # Close the progress report so a drained backlog isn't
+ # left marked partially done (and pointlessly
+ # rescheduled ASAP).
+ self.env["ir.cron"]._commit_progress(0, remaining=0)
+ break
+ chunk.modified(["ended_date"])
+ # The recompute flushes through low-level SQL and bypasses this
+ # model's write() override, so the metric-invalidation hook must
+ # be called explicitly.
+ self._invalidate_group_metrics(chunk.mapped("group"))
+ repaired_ids.update(chunk.ids)
+ self.env.flush_all()
+ # `remaining` is a drained/not-drained signal, not a count.
+ if exhausted:
+ self.env["ir.cron"]._commit_progress(len(chunk), remaining=0)
+ break
+ time_left = self.env["ir.cron"]._commit_progress(len(chunk), remaining=1)
+ if not time_left:
+ _logger.info("[spp.registry] Ended-status backlog remains; the cron will be re-triggered to continue")
+ repaired = memberships.browse(repaired_ids)
+ if repaired:
+ _logger.info(
+ "[spp.registry] Repaired ended-status on %d group membership(s)",
+ len(repaired),
+ )
+ return repaired
@api.constrains("ended_date")
def _check_ended_date(self):
@@ -219,8 +495,7 @@ def _check_ended_date(self):
@api.onchange("ended_date")
def _onchange_ended_date(self):
+ now = fields.Datetime.now()
for record in self:
- record.active = True
# if ended date is less than current date, set active to false
- if record.ended_date and record.ended_date <= fields.Datetime.now():
- record.active = False
+ record.active = not self._is_ended_as_of(record.ended_date, now)
diff --git a/spp_registry/readme/HISTORY.md b/spp_registry/readme/HISTORY.md
index aed786664..f75e01fb1 100644
--- a/spp_registry/readme/HISTORY.md
+++ b/spp_registry/readme/HISTORY.md
@@ -1,3 +1,8 @@
+### 19.0.2.2.3
+
+- fix(registry): repair the stored `status`/`is_ended` computes on `spp.group.membership` once the clock crosses `ended_date`. Both fields depend only on `ended_date` compared against *now*, so a future-dated departure never took effect once the clock crossed it — rosters, metrics, API search and downstream gates kept treating the member as active indefinitely. Writing a future `ended_date` now schedules a lightweight, index-served repair cron in the minute after that moment (staleness window ≈1–2 minutes), and a daily sweep self-heals everything else: rows already stale in existing databases (drained in committed batches, resuming across runs until the backlog is gone) and rows written behind the ORM, including `is_ended = NULL` rows that raw-SQL consumers treated as ended (#417)
+- upgrade note: this version adds a partial index on `spp_group_membership.ended_date`, built with a write-blocking `CREATE INDEX` during the module upgrade. On a very large registry, pre-create it concurrently before upgrading and the upgrade will skip the build: `CREATE INDEX CONCURRENTLY IF NOT EXISTS spp_group_membership__ended_date_index ON spp_group_membership (ended_date) WHERE ended_date IS NOT NULL;`
+
### 19.0.2.2.2
- fix(registry): let an ID type be used again after its ID was removed. Removing an ID through a change request keeps the row and marks it Invalid, and the old uniqueness rule counted those dead rows — so the registrant was left with an Invalid ID and no way to add a valid one of the same type. Uniqueness now applies to live IDs only, and is refused before the write so the message names the ID type rather than surfacing a database error (#1136)
diff --git a/spp_registry/static/description/index.html b/spp_registry/static/description/index.html
index 063ba1ff7..f65bf0c23 100644
--- a/spp_registry/static/description/index.html
+++ b/spp_registry/static/description/index.html
@@ -518,6 +518,30 @@
+
19.0.2.2.3
+
+- fix(registry): repair the stored status/is_ended computes on
+spp.group.membership once the clock crosses ended_date. Both
+fields depend only on ended_date compared against now, so a
+future-dated departure never took effect once the clock crossed it —
+rosters, metrics, API search and downstream gates kept treating the
+member as active indefinitely. Writing a future ended_date now
+schedules a lightweight, index-served repair cron in the minute after
+that moment (staleness window ≈1–2 minutes), and a daily sweep
+self-heals everything else: rows already stale in existing databases
+(drained in committed batches, resuming across runs until the backlog
+is gone) and rows written behind the ORM, including
+is_ended = NULL rows that raw-SQL consumers treated as ended
+(#417)
+- upgrade note: this version adds a partial index on
+spp_group_membership.ended_date, built with a write-blocking
+CREATE INDEX during the module upgrade. On a very large registry,
+pre-create it concurrently before upgrading and the upgrade will skip
+the build:
+CREATE INDEX CONCURRENTLY IF NOT EXISTS spp_group_membership__ended_date_index ON spp_group_membership (ended_date) WHERE ended_date IS NOT NULL;
+
+
+
19.0.2.2.2
- fix(registry): let an ID type be used again after its ID was removed.
@@ -529,7 +553,7 @@
19.0.2.2.2
surfacing a database error (#1136)
-
+
19.0.2.2.1
- feat(registry): registry configuration is consolidated into one
@@ -540,7 +564,7 @@
19.0.2.2.1
framework refuses a settings save from anyone else (#1009)
-
+
19.0.2.1.4
- fix(registry): remove the dead @api.constrains("age")
@@ -552,7 +576,7 @@
19.0.2.1.4
dropped
-
+
19.0.2.1.3
- fix(registry): show an ID Status column on the group form
@@ -563,7 +587,7 @@
19.0.2.1.3
(#1110)
-
+
19.0.2.1.1
- fix(views): add reusable x2many_no_padding JS widget that
@@ -573,7 +597,7 @@
19.0.2.1.1
don’t bloat the layout (#943).
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_registry/tests/__init__.py b/spp_registry/tests/__init__.py
index 2177f1fd9..a5fc2ccb5 100644
--- a/spp_registry/tests/__init__.py
+++ b/spp_registry/tests/__init__.py
@@ -10,6 +10,7 @@
from . import test_phone_number
from . import test_reg_id
from . import test_membership_constraints
+from . import test_membership_status_cron
from . import test_registrant_misc
from . import test_group_aggregation
from . import test_res_config_settings
diff --git a/spp_registry/tests/test_membership_status_cron.py b/spp_registry/tests/test_membership_status_cron.py
new file mode 100644
index 000000000..0afec4827
--- /dev/null
+++ b/spp_registry/tests/test_membership_status_cron.py
@@ -0,0 +1,532 @@
+# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
+"""Repair of stored ``status``/``is_ended`` stale against the clock — see
+``_cron_recompute_ended_status`` (#417) for the full story.
+
+The stale state cannot be produced through the ORM (writing ``ended_date``
+recomputes at write time), so these tests age rows behind the ORM's back with
+raw SQL — exactly how production rows drift.
+"""
+
+from datetime import timedelta
+from unittest.mock import patch
+
+from odoo import fields
+from odoo.tests import tagged
+
+from .test_membership_constraints import MembershipCommon
+from .test_metric_invalidation import _patch_invalidate_funnel
+
+
+@tagged("post_install", "-at_install")
+class TestMembershipEndedStatusCron(MembershipCommon):
+ """``_cron_recompute_ended_status`` — repair rows the clock has crossed."""
+
+ def _make_membership(self, individual, **vals):
+ vals.update({"group": self.group.id, "individual": individual.id})
+ return self.Membership.create(vals)
+
+ def _age_row(self, rec, ended_date=None, active=True):
+ """Rewrite the date window (and ``active``) behind the ORM's back so
+ the stored computes keep their now-wrong values. Defaults to a
+ departure one year ago; ``start_date`` is derived so the row stays
+ consistent with the start/end constraint."""
+ if ended_date is None:
+ ended_date = fields.Datetime.now() - timedelta(days=365)
+ start_date = ended_date - timedelta(days=365)
+ self.env.flush_all()
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET start_date = %s, ended_date = %s, active = %s WHERE id = %s",
+ (start_date, ended_date, active, rec.id),
+ )
+ rec.invalidate_recordset()
+
+ def _read_stored_columns(self, rec):
+ """Read ``status``/``is_ended`` straight from the SQL columns, the way
+ the raw-SQL consumers of ``is_ended`` do — bypassing the ORM cache,
+ which would recompute pending fields on read and mask a missing
+ column write."""
+ self.env.flush_all()
+ self.env.cr.execute(
+ "SELECT status, is_ended FROM spp_group_membership WHERE id = %s",
+ (rec.id,),
+ )
+ return self.env.cr.fetchone()
+
+ def _run_cron(self, model=None, time_left=float("inf"), method="_cron_recompute_ended_status", **kwargs):
+ """Run a repair cron with ``ir.cron._commit_progress`` stubbed out.
+
+ The real method commits, which on a TestCursor releases the test
+ savepoint and leaks this test's rows into the rest of the class.
+ The stub records ``(processed, remaining)`` per chunk so tests can
+ assert the chunking behaviour; ``time_left`` is what the stub
+ reports back as the remaining cron time budget. ``method`` picks
+ the cron entrypoint (daily full sweep by default).
+ """
+ calls = []
+
+ def fake_commit_progress(_cron, processed=0, remaining=None, **_kw):
+ calls.append((processed, remaining))
+ return time_left
+
+ # `is None`, not `or`: a model handle is an empty recordset and
+ # therefore falsy — `model or ...` would silently fall back to the
+ # superuser-bound self.Membership.
+ target = self.Membership if model is None else model
+ with patch.object(
+ type(self.env["ir.cron"]),
+ "_commit_progress",
+ autospec=True,
+ side_effect=fake_commit_progress,
+ ):
+ repaired = getattr(target, method)(**kwargs)
+ return repaired, calls
+
+ def test_cron_ends_membership_the_clock_has_crossed(self):
+ rec = self._make_membership(self.individual_a)
+ self._age_row(rec)
+
+ # Stale precondition: departed a year ago, still stored as active.
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+ self.assertEqual(rec.status, "active")
+ self.assertFalse(rec.is_ended)
+
+ repaired, _calls = self._run_cron()
+
+ self.assertIn(rec, repaired)
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+ # The SQL columns must be repaired too — four consumers read
+ # is_ended in raw SQL and never see the ORM cache.
+ self.assertEqual(self._read_stored_columns(rec), ("inactive", True))
+
+ def test_cron_reactivates_membership_whose_end_moved_to_future(self):
+ past = fields.Datetime.now() - timedelta(days=365)
+ rec = self._make_membership(
+ self.individual_a,
+ start_date=past - timedelta(days=1),
+ ended_date=past,
+ )
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+
+ # The end date is pushed to the future behind the ORM's back; the
+ # stored "inactive" is now wrong in the other direction.
+ future = fields.Datetime.now() + timedelta(days=365)
+ self._age_row(rec, future)
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+
+ repaired, _calls = self._run_cron()
+
+ self.assertIn(rec, repaired)
+ self.assertEqual(rec.status, "active")
+ self.assertFalse(rec.is_ended)
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+
+ def test_cron_repairs_archived_rows(self):
+ rec = self._make_membership(self.individual_a)
+ self._age_row(rec, active=False)
+ self.assertEqual(rec.status, "active")
+ self.assertFalse(rec.is_ended)
+
+ self._run_cron()
+
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+ # The cron repairs the computes only; archiving stays as it was.
+ self.assertFalse(rec.active)
+
+ def test_cron_repairs_null_is_ended_row(self):
+ # A raw INSERT that omits the nullable computed columns: every
+ # raw-SQL consumer treats NULL is_ended as ended, and the ORM alone
+ # cannot repair NULL -> False (the cache reads NULL as False, so a
+ # recompute writes nothing).
+ rec = self._make_membership(self.individual_a)
+ old_stamp = fields.Datetime.now() - timedelta(days=365)
+ self.env.flush_all()
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET is_ended = NULL, status = NULL, ended_date = NULL, "
+ "write_date = %s WHERE id = %s",
+ (old_stamp, rec.id),
+ )
+ rec.invalidate_recordset()
+ self.assertEqual(self._read_stored_columns(rec), (None, None))
+
+ repaired, _calls = self._run_cron()
+
+ self.assertIn(rec, repaired)
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+ # The raw repair stamps write metadata the way the ORM legs' flush
+ # does, so write_date-keyed consumers (incremental syncs, the
+ # API's changed_by) see the repair.
+ self.env.cr.execute(
+ "SELECT write_date, write_uid FROM spp_group_membership WHERE id = %s",
+ (rec.id,),
+ )
+ write_date, write_uid = self.env.cr.fetchone()
+ self.assertGreater(write_date, old_stamp)
+ self.assertEqual(write_uid, self.env.uid)
+
+ def test_cron_repairs_null_columns_with_past_ended_date(self):
+ # NULL is_ended/status with a departure already in the past must be
+ # repaired by the ORM legs: the computed True/"inactive" differs
+ # from the cached False, so — unlike the NULL-and-active case — a
+ # normal recompute does write. Pins the ORM-internals assumption
+ # documented on _repair_null_is_ended.
+ rec = self._make_membership(self.individual_a)
+ now = fields.Datetime.now()
+ self.env.flush_all()
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET is_ended = NULL, status = NULL, "
+ "start_date = %s, ended_date = %s WHERE id = %s",
+ (now - timedelta(days=730), now - timedelta(days=365), rec.id),
+ )
+ rec.invalidate_recordset()
+ self.assertEqual(self._read_stored_columns(rec), (None, None))
+
+ repaired, _calls = self._run_cron()
+
+ self.assertIn(rec, repaired)
+ self.assertEqual(self._read_stored_columns(rec), ("inactive", True))
+
+ def test_cron_repairs_memberships_of_disabled_registrants(self):
+ # Two global ir.rule records hide memberships of disabled
+ # registrants from ordinary users; the cron record pins
+ # user_id=base.user_root precisely so the sweep sees them. An
+ # officer-run sweep misses the row, the root-run sweep repairs it.
+ rec = self._make_membership(self.individual_a)
+ self._age_row(rec)
+ self.individual_a.disabled = fields.Datetime.now()
+ # Flush: the rule domain is evaluated in SQL, which must see the
+ # disabled stamp, not the pending cache value.
+ self.env.flush_all()
+
+ officer = self._make_user("status_cron_officer", ["spp_registry.group_registry_officer"])
+ missed, _calls = self._run_cron(model=self.Membership.with_user(officer))
+ self.assertNotIn(rec, missed)
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+
+ repaired, _calls = self._run_cron()
+ self.assertIn(rec, repaired)
+ self.assertEqual(self._read_stored_columns(rec), ("inactive", True))
+
+ def test_cron_leaves_correct_rows_untouched(self):
+ open_ended = self._make_membership(self.individual_a)
+ past = fields.Datetime.now() - timedelta(days=365)
+ already_ended = self._make_membership(
+ self.individual_b,
+ start_date=past - timedelta(days=1),
+ ended_date=past,
+ )
+
+ repaired, _calls = self._run_cron()
+
+ # 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 & (open_ended | already_ended))
+ self.assertEqual(open_ended.status, "active")
+ self.assertFalse(open_ended.is_ended)
+ self.assertEqual(already_ended.status, "inactive")
+ self.assertTrue(already_ended.is_ended)
+
+ def test_cron_invalidates_group_metrics(self):
+ rec = self._make_membership(self.individual_a)
+ self._age_row(rec)
+
+ # The recompute flushes through low-level SQL and bypasses write(),
+ # so the cron must call the metric-invalidation funnel itself.
+ with _patch_invalidate_funnel(self.env) as funnel:
+ self._run_cron()
+
+ self.assertTrue(funnel.called)
+ invalidated = self.env["res.partner"].browse()
+ for call in funnel.call_args_list:
+ invalidated |= call.args[0]
+ self.assertIn(self.group, invalidated)
+
+ def test_cron_drains_backlog_in_batches(self):
+ carol = self.Partner.create({"name": "Carol", "is_registrant": True, "is_group": False})
+ rows = self.Membership.browse()
+ for individual in (self.individual_a, self.individual_b, carol):
+ rec = self._make_membership(individual)
+ self._age_row(rec)
+ rows |= rec
+
+ repaired, calls = self._run_cron(batch_size=2)
+
+ # One run drains the whole backlog in batch_size chunks, each
+ # reported (and committed) through _commit_progress. Assertions
+ # stay scoped to the three rows this test created — ambient stale
+ # rows on a demo-seeded DB may only add to the totals.
+ self.assertTrue(all(rec in repaired for rec in rows))
+ for rec in rows:
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+ self.assertGreaterEqual(len(calls), 2)
+ self.assertGreaterEqual(sum(processed for processed, _remaining in calls), len(rows))
+ self.assertTrue(all(processed <= 2 for processed, _remaining in calls))
+ # A short final chunk reports the backlog as drained. (Guarded: if
+ # ambient stale rows ever pad the backlog to a multiple of the
+ # batch size, the last full chunk legitimately reports more work.)
+ if calls[-1][0] < 2:
+ self.assertEqual(calls[-1][1], 0)
+
+ def test_cron_resumes_after_time_budget_exhausted(self):
+ # The production path for any real backlog: _commit_progress
+ # reports no time left, the run stops after its current chunk with
+ # the backlog flagged, and the rescheduled run finishes the job.
+ carol = self.Partner.create({"name": "Carol", "is_registrant": True, "is_group": False})
+ rows = self.Membership.browse()
+ for individual in (self.individual_a, self.individual_b, carol):
+ rec = self._make_membership(individual)
+ self._age_row(rec)
+ rows |= rec
+
+ first, calls = self._run_cron(batch_size=1, time_left=0.0)
+
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls[0][0], 1)
+ self.assertNotEqual(calls[0][1], 0) # backlog reported as remaining
+ self.assertEqual(len(first & rows), 1)
+
+ second, _calls = self._run_cron(batch_size=1)
+
+ self.assertEqual((first | second) & rows, rows)
+ for rec in rows:
+ self.assertEqual(rec.status, "inactive")
+ self.assertTrue(rec.is_ended)
+
+ def test_overlapping_legs_do_not_end_run_early(self):
+ # Rows stale on both columns match the first two legs at once and
+ # the chunk union de-duplicates, so a chunk shorter than
+ # batch_size is no proof the backlog is drained — exhaustion must
+ # be tracked per leg, or rows stale on one column only are left
+ # behind with the run reported fully done.
+ status_only = self.Membership.browse()
+ for name in ("Dave", "Erin"):
+ individual = self.Partner.create({"name": name, "is_registrant": True, "is_group": False})
+ rec = self._make_membership(individual)
+ self._age_row(rec)
+ # is_ended already correct; only status is stale (leg 2 only).
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET is_ended = true WHERE id = %s",
+ (rec.id,),
+ )
+ rec.invalidate_recordset()
+ status_only |= rec
+ # Newer ids — searched first under `id desc` — stale on both.
+ carol = self.Partner.create({"name": "Carol", "is_registrant": True, "is_group": False})
+ both_stale = self.Membership.browse()
+ for individual in (self.individual_a, self.individual_b, carol):
+ rec = self._make_membership(individual)
+ self._age_row(rec)
+ both_stale |= rec
+
+ repaired, calls = self._run_cron(batch_size=4)
+
+ # One run repairs every row: pass 1 fills mostly from leg 1 and
+ # its leg-2 quota only re-finds chunk rows, so a union-size
+ # drained check would have stopped here with the status-only rows
+ # unrepaired until the next daily sweep.
+ rows = both_stale | status_only
+ self.assertEqual(repaired & rows, rows)
+ for rec in rows:
+ self.assertEqual(self._read_stored_columns(rec), ("inactive", True))
+ self.assertEqual(calls[-1][1], 0)
+
+ def test_crossed_cron_repairs_only_index_served_legs(self):
+ crossed = self._make_membership(self.individual_a)
+ self._age_row(crossed)
+ null_row = self._make_membership(self.individual_b)
+ self.env.flush_all()
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET is_ended = NULL, status = NULL, ended_date = NULL WHERE id = %s",
+ (null_row.id,),
+ )
+ null_row.invalidate_recordset()
+
+ repaired, _calls = self._run_cron(method="_cron_repair_crossed_ended_status")
+
+ self.assertIn(crossed, repaired)
+ self.assertEqual(self._read_stored_columns(crossed), ("inactive", True))
+ # NULL drift needs full-scan probes; the trigger-driven cron runs
+ # per departure and must leave those to the daily safety net.
+ self.assertNotIn(null_row, repaired)
+ self.assertEqual(self._read_stored_columns(null_row), (None, None))
+
+ repaired, _calls = self._run_cron()
+
+ self.assertIn(null_row, repaired)
+ self.assertEqual(self._read_stored_columns(null_row), ("active", False))
+
+ def _make_null_rows(self):
+ rows = self.Membership.browse()
+ for individual in (self.individual_a, self.individual_b):
+ rows |= self._make_membership(individual)
+ self.env.flush_all()
+ self.env.cr.execute(
+ "UPDATE spp_group_membership SET is_ended = NULL, ended_date = NULL WHERE id IN %s",
+ (tuple(rows.ids),),
+ )
+ rows.invalidate_recordset()
+ return rows
+
+ def test_null_repair_drains_in_batches(self):
+ rows = self._make_null_rows()
+
+ repaired, calls = self._run_cron(batch_size=1)
+
+ self.assertEqual(repaired & rows, rows)
+ for rec in rows:
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+ # Every batch is bounded and reported; the run closes with the
+ # backlog reported drained.
+ self.assertTrue(all(processed <= 1 for processed, _remaining in calls))
+ self.assertGreaterEqual(sum(processed for processed, _remaining in calls), len(rows))
+ self.assertEqual(calls[-1][1], 0)
+
+ def test_null_repair_stops_when_time_budget_exhausted(self):
+ # A NULL row with no ended_date matches none of the ORM legs, so
+ # the NULL loop itself must report the backlog: with the budget
+ # gone after one batch, the run stops flagged partially done and
+ # is continued ASAP — not a day later on a "fully done" report.
+ rows = self._make_null_rows()
+
+ first, calls = self._run_cron(batch_size=1, time_left=0.0)
+
+ self.assertEqual(calls, [(1, 1)])
+ self.assertEqual(len(first & rows), 1)
+
+ second, _calls = self._run_cron(batch_size=1)
+
+ self.assertEqual((first | second) & rows, rows)
+ for rec in rows:
+ self.assertEqual(self._read_stored_columns(rec), ("active", False))
+
+ def test_future_ended_date_schedules_cron_trigger(self):
+ cron = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ Trigger = self.env["ir.cron.trigger"]
+ # Every departure is scheduled in the minute *after* ended_date —
+ # even a minute-aligned one: the cron machinery consumes triggers
+ # against the database clock while the repair predicate compares
+ # the application clock, so an exact-time trigger could be
+ # consumed a hair before the row reads as ended.
+ future = fields.Datetime.now().replace(second=0) + timedelta(days=30)
+
+ before = Trigger.search([("cron_id", "=", cron.id)])
+ rec = self._make_membership(self.individual_a, ended_date=future)
+ created = Trigger.search([("cron_id", "=", cron.id)]) - before
+ self.assertEqual(len(created), 1)
+ self.assertEqual(created.call_at, future + timedelta(minutes=1))
+
+ # A mid-minute departure lands in the next full minute as well,
+ # never scheduled at or before the date itself.
+ later = future + timedelta(days=5, seconds=30)
+ before = Trigger.search([("cron_id", "=", cron.id)])
+ rec.write({"ended_date": later})
+ created = Trigger.search([("cron_id", "=", cron.id)]) - before
+ self.assertEqual(len(created), 1)
+ self.assertEqual(created.call_at, later.replace(second=0) + timedelta(minutes=1))
+
+ def test_default_ended_date_context_schedules_cron_trigger(self):
+ # default_get fills a missing ended_date from the context; the
+ # trigger must still be scheduled (create reads the records back,
+ # not the raw vals).
+ cron = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ Trigger = self.env["ir.cron.trigger"]
+ future = fields.Datetime.now().replace(second=0) + timedelta(days=30)
+
+ before = Trigger.search([("cron_id", "=", cron.id)])
+ rec = self.Membership.with_context(default_ended_date=future).create(
+ {"group": self.group.id, "individual": self.individual_a.id}
+ )
+ self.assertEqual(rec.ended_date, future)
+ created = Trigger.search([("cron_id", "=", cron.id)]) - before
+ self.assertEqual(len(created), 1)
+ self.assertEqual(created.call_at, future + timedelta(minutes=1))
+
+ def test_past_ended_date_schedules_no_cron_trigger(self):
+ # A past departure is recomputed correctly at write time; only a
+ # future one needs the clock-crossing repair scheduled.
+ cron = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ Trigger = self.env["ir.cron.trigger"]
+ past = fields.Datetime.now() - timedelta(days=365)
+
+ before = Trigger.search([("cron_id", "=", cron.id)])
+ rec = self._make_membership(
+ self.individual_a,
+ start_date=past - timedelta(days=1),
+ ended_date=past,
+ )
+ rec.write({"ended_date": past + timedelta(days=1)})
+ self.assertFalse(Trigger.search([("cron_id", "=", cron.id)]) - before)
+
+ def test_same_minute_departures_share_one_trigger(self):
+ # A cohort exit written one membership per call must not file one
+ # trigger row per membership — pending triggers for the same
+ # minute are reused (ir.cron._trigger itself never de-duplicates
+ # across calls).
+ cron = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ Trigger = self.env["ir.cron.trigger"]
+ future = fields.Datetime.now().replace(second=0) + timedelta(days=30)
+
+ before = Trigger.search([("cron_id", "=", cron.id)])
+ self._make_membership(self.individual_a, ended_date=future)
+ self._make_membership(self.individual_b, ended_date=future + timedelta(seconds=30))
+ created = Trigger.search([("cron_id", "=", cron.id)]) - before
+ self.assertEqual(len(created), 1)
+ self.assertEqual(created.call_at, future + timedelta(minutes=1))
+
+ def test_missing_trigger_cron_degrades_to_daily_sweep(self):
+ # The trigger cron is a latency optimisation. If the noupdate
+ # record was deleted by an admin, writing a membership must still
+ # succeed — the daily sweep repairs the row eventually — rather
+ # than raising from env.ref on the core registry write path.
+ self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status").unlink()
+ future = fields.Datetime.now() + timedelta(days=30)
+
+ rec = self._make_membership(self.individual_a, ended_date=future)
+
+ self.assertEqual(rec.ended_date, future)
+
+ def test_cron_record_registered(self):
+ # Daily full safety net — the common path is the per-departure
+ # trigger on the crossed cron below.
+ cron = self.env.ref("spp_registry.cron_recompute_membership_ended_status")
+ self.assertEqual(cron.model_id.model, "spp.group.membership")
+ self.assertTrue(cron.active)
+ self.assertIn("_cron_recompute_ended_status", cron.code)
+ self.assertEqual(cron.interval_number, 1)
+ self.assertEqual(cron.interval_type, "days")
+ # Trigger target for exact-time repairs: crossed legs only, so a
+ # per-departure run stays index-served.
+ crossed = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ self.assertEqual(crossed.model_id.model, "spp.group.membership")
+ self.assertTrue(crossed.active)
+ self.assertIn("_cron_repair_crossed_ended_status", crossed.code)
+ # Superuser pin on both: the unsudo'd searches must see
+ # memberships of disabled registrants despite the global ir.rule
+ # pair.
+ root = self.env.ref("base.user_root")
+ self.assertEqual(cron.user_id, root)
+ self.assertEqual(crossed.user_id, root)
+
+ def test_scheduled_sweeps_are_offset(self):
+ # Both crons sweep the same two crossed legs, and
+ # `_order = "id desc"` makes their `search(leg, limit=quota)`
+ # calls return the same rows in the same order. With a shared
+ # nextcall two cron workers pick the two jobs at once (each
+ # ir_cron row is taken with FOR NO KEY UPDATE SKIP LOCKED, so
+ # neither blocks the other) and UPDATE the same ids; under
+ # Odoo's REPEATABLE READ the run that commits second dies with
+ # "could not serialize access due to concurrent update" and is
+ # logged as a failure. The window is widest on the first sweep
+ # after upgrading a registry with a stale backlog. Their
+ # scheduled runs must therefore start apart — both being daily,
+ # the initial offset is preserved on every later run.
+ daily = self.env.ref("spp_registry.cron_recompute_membership_ended_status")
+ crossed = self.env.ref("spp_registry.cron_repair_crossed_membership_ended_status")
+ self.assertGreaterEqual(
+ abs(daily.nextcall - crossed.nextcall),
+ timedelta(minutes=30),
+ )