Skip to content

Commit 21089c5

Browse files
Merge pull request #149 from OpenSPP/worktree-perf+phase7-bulk-membership
perf: bulk membership creation with INSERT ON CONFLICT DO NOTHING
2 parents 5a7544e + cd20329 commit 21089c5

10 files changed

Lines changed: 377 additions & 43 deletions

File tree

spp_programs/README.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,14 @@ Dependencies
254254
Changelog
255255
=========
256256

257+
19.0.2.0.7
258+
~~~~~~~~~~
259+
260+
- Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING
261+
for program and cycle memberships
262+
- Replace per-record ORM creates in ``_import_registrants`` and
263+
``_add_beneficiaries`` with bulk SQL path
264+
257265
19.0.2.0.6
258266
~~~~~~~~~~
259267

spp_programs/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"name": "OpenSPP Programs",
55
"summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.",
66
"category": "OpenSPP/Core",
7-
"version": "19.0.2.0.6",
7+
"version": "19.0.2.0.7",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_programs/models/cycle_membership.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2-
from odoo import _, fields, models
2+
import logging
3+
4+
from odoo import _, api, fields, models
35
from odoo.exceptions import ValidationError
46

7+
_logger = logging.getLogger(__name__)
8+
59

610
class SPPCycleMembership(models.Model):
711
_name = "spp.cycle.membership"
@@ -87,6 +91,73 @@ def open_registrant_form(self):
8791
},
8892
}
8993

94+
@api.model
95+
def bulk_create_memberships(self, vals_list, chunk_size=1000, skip_duplicates=False):
96+
"""Create cycle memberships in bulk with optional duplicate skipping.
97+
98+
:param vals_list: List of dicts with membership values
99+
:param chunk_size: Number of records per batch (default 1000)
100+
:param skip_duplicates: When True, use INSERT ... ON CONFLICT DO NOTHING
101+
to silently skip duplicate (partner_id, cycle_id) pairs.
102+
Returns the count of inserted rows.
103+
:return: Recordset (skip_duplicates=False) or int count (skip_duplicates=True)
104+
"""
105+
if not vals_list:
106+
return 0 if skip_duplicates else self.env["spp.cycle.membership"]
107+
108+
if skip_duplicates:
109+
return self._bulk_insert_on_conflict(vals_list, chunk_size)
110+
111+
return self.create(vals_list)
112+
113+
def _bulk_insert_on_conflict(self, vals_list, chunk_size=1000):
114+
"""Insert cycle memberships using raw SQL with ON CONFLICT DO NOTHING.
115+
116+
:param vals_list: List of dicts with at least partner_id, cycle_id, state
117+
:param chunk_size: Number of records per SQL INSERT batch
118+
:return: Total number of rows actually inserted
119+
"""
120+
cr = self.env.cr
121+
uid = self.env.uid
122+
total_inserted = 0
123+
today = fields.Date.today()
124+
125+
for i in range(0, len(vals_list), chunk_size):
126+
batch = vals_list[i : i + chunk_size]
127+
values = []
128+
params = []
129+
for v in batch:
130+
values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
131+
params.extend(
132+
[
133+
v["partner_id"],
134+
v["cycle_id"],
135+
v.get("state", "draft"),
136+
v.get("enrollment_date", today),
137+
uid,
138+
uid,
139+
]
140+
)
141+
142+
sql = """
143+
INSERT INTO spp_cycle_membership
144+
(partner_id, cycle_id, state, enrollment_date,
145+
create_uid, write_uid, create_date, write_date)
146+
VALUES {}
147+
ON CONFLICT (partner_id, cycle_id) DO NOTHING
148+
""".format( # noqa: S608 # nosec B608
149+
", ".join(values)
150+
)
151+
cr.execute(sql, params)
152+
total_inserted += cr.rowcount
153+
154+
_logger.info(
155+
"Bulk inserted %d cycle memberships (%d skipped as duplicates)",
156+
total_inserted,
157+
len(vals_list) - total_inserted,
158+
)
159+
return total_inserted
160+
90161
def unlink(self):
91162
if not self:
92163
return

spp_programs/models/managers/cycle_manager_base.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -835,25 +835,25 @@ def _add_beneficiaries(self, cycle, beneficiaries, state="draft", do_count=False
835835
"""Add Beneficiaries
836836
837837
:param cycle: Recordset of cycle
838-
:param beneficiaries: Recordset of beneficiaries
838+
:param beneficiaries: List of partner IDs
839839
:param state: String state to be set to beneficiary
840840
:param do_count: Boolean - set to False to not run compute functions
841-
:return: Integer - count of not enrolled members
842-
"""
843-
new_beneficiaries = []
844-
for r in beneficiaries:
845-
new_beneficiaries.append(
846-
[
847-
0,
848-
0,
849-
{
850-
"partner_id": r,
851-
"enrollment_date": fields.Date.today(),
852-
"state": state,
853-
},
854-
]
855-
)
856-
cycle.update({"cycle_membership_ids": new_beneficiaries})
841+
"""
842+
today = fields.Date.today()
843+
vals_list = [
844+
{
845+
"partner_id": partner_id,
846+
"cycle_id": cycle.id,
847+
"enrollment_date": today,
848+
"state": state,
849+
}
850+
for partner_id in beneficiaries
851+
]
852+
self.env["spp.cycle.membership"].bulk_create_memberships(vals_list, skip_duplicates=True)
853+
854+
# Raw SQL bypasses the ORM cache — invalidate so subsequent reads
855+
# (e.g. cycle.cycle_membership_ids) reflect the new rows.
856+
cycle.invalidate_recordset(["cycle_membership_ids"])
857857

858858
if do_count:
859859
# Update Statistics

spp_programs/models/managers/eligibility_manager.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
import logging
33

4-
from odoo import Command, _, api, fields, models
4+
from odoo import _, api, fields, models
55

66
from odoo.addons.job_worker.delay import group
77

@@ -174,11 +174,13 @@ def mark_import_as_done(self):
174174

175175
def _import_registrants(self, new_beneficiaries, state="draft", do_count=False):
176176
_logger.info("Importing %s beneficiaries", len(new_beneficiaries))
177-
_logger.info("updated")
178-
beneficiaries_val = []
179-
for beneficiary in new_beneficiaries:
180-
beneficiaries_val.append(Command.create({"partner_id": beneficiary.id, "state": state}))
181-
self.program_id.update({"program_membership_ids": beneficiaries_val})
177+
vals_list = [{"partner_id": b.id, "program_id": self.program_id.id, "state": state} for b in new_beneficiaries]
178+
count = self.env["spp.program.membership"].bulk_create_memberships(vals_list, skip_duplicates=True)
179+
_logger.info("Imported %d new memberships (%d duplicates skipped)", count, len(vals_list) - count)
180+
181+
# Raw SQL bypasses the ORM cache — invalidate so subsequent reads
182+
# (e.g. program.program_membership_ids) reflect the new rows.
183+
self.program_id.invalidate_recordset(["program_membership_ids"])
182184

183185
if do_count:
184186
# Compute Statistics

spp_programs/models/program_membership.py

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
2+
import logging
23

34
from lxml import etree
45

@@ -7,6 +8,8 @@
78

89
from . import constants
910

11+
_logger = logging.getLogger(__name__)
12+
1013

1114
class SPPProgramMembership(models.Model):
1215
_inherit = [
@@ -345,26 +348,26 @@ def action_exit(self):
345348
}
346349
)
347350

348-
@api.model_create_multi
349-
def bulk_create_memberships(self, vals_list, chunk_size=1000):
351+
@api.model
352+
def bulk_create_memberships(self, vals_list, chunk_size=1000, skip_duplicates=False):
350353
"""Create program memberships in bulk with optional chunking.
351354
352355
This helper is intended for large enrollment jobs (e.g. CEL-driven
353356
bulk enrollment) where thousands of memberships need to be created
354357
in a single operation.
355358
356-
It preserves the normal create() semantics, including:
357-
- standard ORM validations and constraints
358-
- audit logging (via spp_audit rules)
359-
- source tracking mixins
360-
361-
The only optimisation is to:
362-
- accept already-prepared value dicts
363-
- optionally split very large batches into smaller chunks to keep
364-
memory use and per-transaction work bounded.
359+
:param vals_list: List of dicts with membership values
360+
:param chunk_size: Number of records per batch (default 1000)
361+
:param skip_duplicates: When True, use INSERT ... ON CONFLICT DO NOTHING
362+
to silently skip duplicate (partner_id, program_id) pairs instead of
363+
raising IntegrityError. Returns the count of inserted rows.
364+
:return: Recordset (skip_duplicates=False) or int count (skip_duplicates=True)
365365
"""
366366
if not vals_list:
367-
return self.env["spp.program.membership"]
367+
return 0 if skip_duplicates else self.env["spp.program.membership"]
368+
369+
if skip_duplicates:
370+
return self._bulk_insert_on_conflict(vals_list, chunk_size)
368371

369372
if chunk_size and chunk_size > 0:
370373
all_memberships = self.env["spp.program.membership"]
@@ -386,3 +389,60 @@ def bulk_create_memberships(self, vals_list, chunk_size=1000):
386389
SPPProgramMembership,
387390
self.sudo(), # nosemgrep: odoo-sudo-without-context
388391
).create(vals_list)
392+
393+
def _bulk_insert_on_conflict(self, vals_list, chunk_size=1000):
394+
"""Insert memberships using raw SQL with ON CONFLICT DO NOTHING.
395+
396+
Bypasses ORM for maximum throughput during bulk enrollment. Duplicates
397+
(matching the UNIQUE constraint on partner_id, program_id) are silently
398+
skipped.
399+
400+
:param vals_list: List of dicts with at least partner_id, program_id, state
401+
:param chunk_size: Number of records per SQL INSERT batch
402+
:return: Total number of rows actually inserted
403+
"""
404+
cr = self.env.cr
405+
uid = self.env.uid
406+
total_inserted = 0
407+
408+
now = fields.Datetime.now()
409+
410+
for i in range(0, len(vals_list), chunk_size):
411+
batch = vals_list[i : i + chunk_size]
412+
values = []
413+
params = []
414+
for v in batch:
415+
state = v.get("state", "draft")
416+
enrollment_date = now if state == "enrolled" else None
417+
values.append("(%s, %s, %s, %s, %s, %s, %s, now(), now())")
418+
params.extend(
419+
[
420+
v["partner_id"],
421+
v["program_id"],
422+
state,
423+
enrollment_date,
424+
v.get("deduplication_status", "new"),
425+
uid,
426+
uid,
427+
]
428+
)
429+
430+
sql = """
431+
INSERT INTO spp_program_membership
432+
(partner_id, program_id, state, enrollment_date,
433+
deduplication_status,
434+
create_uid, write_uid, create_date, write_date)
435+
VALUES {}
436+
ON CONFLICT (partner_id, program_id) DO NOTHING
437+
""".format( # noqa: S608 # nosec B608
438+
", ".join(values)
439+
)
440+
cr.execute(sql, params)
441+
total_inserted += cr.rowcount
442+
443+
_logger.info(
444+
"Bulk inserted %d program memberships (%d skipped as duplicates)",
445+
total_inserted,
446+
len(vals_list) - total_inserted,
447+
)
448+
return total_inserted

spp_programs/readme/HISTORY.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### 19.0.2.0.7
2+
3+
- Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING for program and cycle memberships
4+
- Replace per-record ORM creates in `_import_registrants` and `_add_beneficiaries` with bulk SQL path
5+
16
### 19.0.2.0.6
27

38
- Remove unused entitlement_base_model.py (dead code, never imported)

spp_programs/static/description/index.html

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,15 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
658658
</div>
659659
</div>
660660
<div class="section" id="section-1">
661+
<h1>19.0.2.0.7</h1>
662+
<ul class="simple">
663+
<li>Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING
664+
for program and cycle memberships</li>
665+
<li>Replace per-record ORM creates in <tt class="docutils literal">_import_registrants</tt> and
666+
<tt class="docutils literal">_add_beneficiaries</tt> with bulk SQL path</li>
667+
</ul>
668+
</div>
669+
<div class="section" id="section-2">
661670
<h1>19.0.2.0.6</h1>
662671
<ul class="simple">
663672
<li>Remove unused entitlement_base_model.py (dead code, never imported)</li>
@@ -666,34 +675,34 @@ <h1>19.0.2.0.6</h1>
666675
payment, and fund tests (172 → 492 tests)</li>
667676
</ul>
668677
</div>
669-
<div class="section" id="section-2">
678+
<div class="section" id="section-3">
670679
<h1>19.0.2.0.5</h1>
671680
<ul class="simple">
672681
<li>Batch create entitlements and payments instead of one-by-one ORM
673682
creates</li>
674683
</ul>
675684
</div>
676-
<div class="section" id="section-3">
685+
<div class="section" id="section-4">
677686
<h1>19.0.2.0.4</h1>
678687
<ul class="simple">
679688
<li>Fetch fund balance once per approval batch instead of per entitlement</li>
680689
</ul>
681690
</div>
682-
<div class="section" id="section-4">
691+
<div class="section" id="section-5">
683692
<h1>19.0.2.0.3</h1>
684693
<ul class="simple">
685694
<li>Replace cycle computed fields (total_amount, entitlements_count,
686695
approval flags) with SQL aggregation queries</li>
687696
</ul>
688697
</div>
689-
<div class="section" id="section-5">
698+
<div class="section" id="section-6">
690699
<h1>19.0.2.0.2</h1>
691700
<ul class="simple">
692701
<li>Add composite indexes for frequent query patterns on entitlements and
693702
program memberships</li>
694703
</ul>
695704
</div>
696-
<div class="section" id="section-6">
705+
<div class="section" id="section-7">
697706
<h1>19.0.2.0.1</h1>
698707
<ul class="simple">
699708
<li>Replace Python-level uniqueness checks with SQL UNIQUE constraints for
@@ -702,7 +711,7 @@ <h1>19.0.2.0.1</h1>
702711
constraint creation</li>
703712
</ul>
704713
</div>
705-
<div class="section" id="section-7">
714+
<div class="section" id="section-8">
706715
<h1>19.0.2.0.0</h1>
707716
<ul class="simple">
708717
<li>Initial migration to OpenSPP2</li>

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@
3232
from . import test_payment_and_accounting
3333
from . import test_managers
3434
from . import test_cycle_auto_approve_fund_check
35+
from . import test_bulk_membership

0 commit comments

Comments
 (0)