Skip to content

Commit ff8a48a

Browse files
kneckinatorgonzalesedwin1123
authored andcommitted
perf: add canary patterns to skip statistics during bulk operations
Add context flags (skip_registrant_statistics, skip_program_statistics) that allow bulk operation callers to suppress expensive computed field recomputation. Add refresh_beneficiary_counts() on spp.program and refresh_statistics() on spp.cycle to recompute once at completion. Also replace bool(rec.program_membership_ids) with SQL EXISTS in _compute_has_members to avoid loading the full membership recordset.
1 parent 26b9060 commit ff8a48a

7 files changed

Lines changed: 173 additions & 5 deletions

File tree

spp_programs/models/cycle.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,16 @@ def _compute_entitlements_count(self):
275275
entitlements_count = self.env["spp.entitlement"].search_count([("cycle_id", "=", rec.id)])
276276
rec.entitlements_count = entitlements_count
277277

278+
def refresh_statistics(self):
279+
"""Refresh all cycle statistics after bulk operations.
280+
281+
Call this after raw SQL inserts that bypass ORM dependency tracking
282+
(e.g. bulk_create_memberships with skip_duplicates=True).
283+
"""
284+
self._compute_members_count()
285+
self._compute_entitlements_count()
286+
self._compute_total_entitlements_count()
287+
278288
@api.depends("entitlement_ids", "inkind_entitlement_ids")
279289
def _compute_total_entitlements_count(self):
280290
if not self.ids:

spp_programs/models/managers/cycle_manager_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,8 @@ def mark_import_as_done(self, cycle, msg):
326326
cycle.locked_reason = None
327327
cycle.message_post(body=msg)
328328

329-
# Update Statistics
330-
cycle._compute_members_count()
329+
# Refresh statistics after bulk operations
330+
cycle.refresh_statistics()
331331

332332
def mark_prepare_entitlement_as_done(self, cycle, msg):
333333
"""Complete the preparation of entitlements.

spp_programs/models/managers/eligibility_manager.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,7 @@ def _import_registrants_async(self, new_beneficiaries, state="draft"):
165165

166166
def mark_import_as_done(self):
167167
self.ensure_one()
168-
self.program_id._compute_eligible_beneficiary_count()
169-
self.program_id._compute_beneficiary_count()
168+
self.program_id.refresh_beneficiary_counts()
170169

171170
self.program_id.is_locked = False
172171
self.program_id.locked_reason = None

spp_programs/models/programs.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,23 @@ def _check_unique_program_name(self):
187187

188188
@api.depends("program_membership_ids")
189189
def _compute_has_members(self):
190+
if self.env.context.get("skip_program_statistics"):
191+
return
192+
if not self.ids:
193+
for rec in self:
194+
rec.has_members = False
195+
return
196+
self.env.cr.execute(
197+
"""
198+
SELECT program_id FROM spp_program_membership
199+
WHERE program_id IN %s
200+
GROUP BY program_id
201+
""",
202+
(tuple(self.ids),),
203+
)
204+
programs_with_members = {row[0] for row in self.env.cr.fetchall()}
190205
for rec in self:
191-
rec.has_members = bool(rec.program_membership_ids)
206+
rec.has_members = rec.id in programs_with_members
192207

193208
@api.depends("compliance_manager_ids", "compliance_manager_ids.manager_ref_id")
194209
def _compute_has_compliance_criteria(self):
@@ -273,6 +288,16 @@ def _compute_beneficiary_count(self):
273288
count = rec.count_beneficiaries(None)["value"]
274289
rec.update({"beneficiaries_count": count})
275290

291+
def refresh_beneficiary_counts(self):
292+
"""Refresh all beneficiary statistics after bulk operations.
293+
294+
Call this after raw SQL inserts that bypass ORM dependency tracking
295+
(e.g. bulk_create_memberships with skip_duplicates=True).
296+
"""
297+
self._compute_beneficiary_count()
298+
self._compute_eligible_beneficiary_count()
299+
self._compute_has_members()
300+
276301
@api.depends("cycle_ids")
277302
def _compute_cycle_count(self):
278303
for rec in self:

spp_programs/models/registrant.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ def _compute_total_entitlements_count(self):
4343
@api.depends("program_membership_ids")
4444
def _compute_program_membership_count(self):
4545
"""Batch-efficient program membership count using read_group."""
46+
if self.env.context.get("skip_registrant_statistics"):
47+
return
4648
if not self:
4749
return
4850

@@ -66,6 +68,8 @@ def _compute_program_membership_count(self):
6668
@api.depends("entitlement_ids")
6769
def _compute_entitlements_count(self):
6870
"""Batch-efficient entitlements count using _read_group."""
71+
if self.env.context.get("skip_registrant_statistics"):
72+
return
6973
if not self:
7074
return
7175

@@ -89,6 +93,8 @@ def _compute_entitlements_count(self):
8993
@api.depends("cycle_ids")
9094
def _compute_cycle_count(self):
9195
"""Batch-efficient cycle membership count using _read_group."""
96+
if self.env.context.get("skip_registrant_statistics"):
97+
return
9298
if not self:
9399
return
94100

@@ -112,6 +118,8 @@ def _compute_cycle_count(self):
112118
@api.depends("inkind_entitlement_ids")
113119
def _compute_inkind_entitlements_count(self):
114120
"""Batch-efficient in-kind entitlements count using _read_group."""
121+
if self.env.context.get("skip_registrant_statistics"):
122+
return
115123
if not self:
116124
return
117125

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,4 @@
3434
from . import test_cycle_auto_approve_fund_check
3535
from . import test_bulk_membership
3636
from . import test_keyset_pagination
37+
from . import test_canary_patterns
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
"""Tests for Phase 8: Canary patterns for bulk operations.
3+
4+
During bulk operations, expensive computed field recomputation should be
5+
skipped via context flags and refreshed once at completion.
6+
"""
7+
8+
import uuid
9+
10+
from odoo import fields
11+
from odoo.tests import TransactionCase
12+
13+
14+
class TestRegistrantCanaryFlags(TransactionCase):
15+
"""Test that registrant statistics skip recomputation with context flags."""
16+
17+
def setUp(self):
18+
super().setUp()
19+
self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
20+
self.partner = self.env["res.partner"].create({"name": "Test Registrant", "is_registrant": True})
21+
22+
def test_skip_registrant_statistics_skips_program_membership_count(self):
23+
"""With skip_registrant_statistics, _compute_program_membership_count should be a no-op."""
24+
self.partner.with_context(skip_registrant_statistics=True)._compute_program_membership_count()
25+
# Value should remain at default (0) since compute was skipped
26+
self.assertEqual(self.partner.program_membership_count, 0)
27+
28+
def test_skip_registrant_statistics_skips_entitlements_count(self):
29+
"""With skip_registrant_statistics, _compute_entitlements_count should be a no-op."""
30+
self.partner.with_context(skip_registrant_statistics=True)._compute_entitlements_count()
31+
self.assertEqual(self.partner.entitlements_count, 0)
32+
33+
def test_skip_registrant_statistics_skips_cycle_count(self):
34+
"""With skip_registrant_statistics, _compute_cycle_count should be a no-op."""
35+
self.partner.with_context(skip_registrant_statistics=True)._compute_cycle_count()
36+
self.assertEqual(self.partner.cycles_count, 0)
37+
38+
def test_skip_registrant_statistics_skips_inkind_count(self):
39+
"""With skip_registrant_statistics, _compute_inkind_entitlements_count should be a no-op."""
40+
self.partner.with_context(skip_registrant_statistics=True)._compute_inkind_entitlements_count()
41+
self.assertEqual(self.partner.inkind_entitlements_count, 0)
42+
43+
def test_without_flag_computes_normally(self):
44+
"""Without the flag, compute methods should work normally."""
45+
self.env["spp.program.membership"].create(
46+
{
47+
"partner_id": self.partner.id,
48+
"program_id": self.program.id,
49+
"state": "draft",
50+
}
51+
)
52+
self.partner._compute_program_membership_count()
53+
self.assertEqual(self.partner.program_membership_count, 1)
54+
55+
56+
class TestProgramCanaryFlags(TransactionCase):
57+
"""Test that program statistics skip recomputation with context flags."""
58+
59+
def setUp(self):
60+
super().setUp()
61+
self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
62+
63+
def test_skip_program_statistics_skips_has_members(self):
64+
"""With skip_program_statistics, _compute_has_members should be a no-op."""
65+
self.program.with_context(skip_program_statistics=True)._compute_has_members()
66+
self.assertFalse(self.program.has_members)
67+
68+
def test_has_members_uses_sql_exists(self):
69+
"""_compute_has_members should detect members without loading the recordset."""
70+
partner = self.env["res.partner"].create({"name": "Registrant", "is_registrant": True})
71+
self.env["spp.program.membership"].create(
72+
{
73+
"partner_id": partner.id,
74+
"program_id": self.program.id,
75+
"state": "draft",
76+
}
77+
)
78+
self.program._compute_has_members()
79+
self.assertTrue(self.program.has_members)
80+
81+
82+
class TestRefreshMethods(TransactionCase):
83+
"""Test refresh_beneficiary_counts and refresh_statistics methods."""
84+
85+
def setUp(self):
86+
super().setUp()
87+
self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"})
88+
self.cycle = self.env["spp.cycle"].create(
89+
{
90+
"name": "Test Cycle",
91+
"program_id": self.program.id,
92+
"start_date": fields.Date.today(),
93+
"end_date": fields.Date.today(),
94+
}
95+
)
96+
self.partners = self.env["res.partner"].create(
97+
[{"name": f"Registrant {i}", "is_registrant": True} for i in range(5)]
98+
)
99+
100+
def test_program_refresh_beneficiary_counts(self):
101+
"""refresh_beneficiary_counts should update all program statistics."""
102+
# Create memberships via SQL (bypassing ORM triggers)
103+
for p in self.partners:
104+
self.env["spp.program.membership"].bulk_create_memberships(
105+
[{"partner_id": p.id, "program_id": self.program.id, "state": "enrolled"}],
106+
skip_duplicates=True,
107+
)
108+
109+
# Counts are stale (SQL insert bypassed ORM)
110+
self.program.refresh_beneficiary_counts()
111+
112+
self.assertEqual(self.program.beneficiaries_count, 5)
113+
self.assertEqual(self.program.eligible_beneficiaries_count, 5)
114+
self.assertTrue(self.program.has_members)
115+
116+
def test_cycle_refresh_statistics(self):
117+
"""refresh_statistics should update cycle member and entitlement counts."""
118+
for p in self.partners:
119+
self.env["spp.cycle.membership"].bulk_create_memberships(
120+
[{"partner_id": p.id, "cycle_id": self.cycle.id, "state": "enrolled"}],
121+
skip_duplicates=True,
122+
)
123+
124+
self.cycle.refresh_statistics()
125+
self.assertEqual(self.cycle.members_count, 5)

0 commit comments

Comments
 (0)