Skip to content

Commit 7b67c1a

Browse files
committed
perf: bulk membership creation with INSERT ON CONFLICT DO NOTHING
Replace per-record ORM creates and Command.create() tuples with raw SQL INSERT ... ON CONFLICT (unique_cols) DO NOTHING for bulk membership creation. Duplicates are silently skipped and the inserted count is returned via cursor.rowcount. Updates _import_registrants and _add_beneficiaries to use the new skip_duplicates path, with ORM cache invalidation after raw SQL inserts.
1 parent f738582 commit 7b67c1a

7 files changed

Lines changed: 342 additions & 37 deletions

File tree

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: 71 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,72 @@ 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+
124+
for i in range(0, len(vals_list), chunk_size):
125+
batch = vals_list[i : i + chunk_size]
126+
values = []
127+
params = []
128+
for v in batch:
129+
values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
130+
params.extend(
131+
[
132+
v["partner_id"],
133+
v["cycle_id"],
134+
v.get("state", "draft"),
135+
v.get("enrollment_date", fields.Date.today()),
136+
uid,
137+
uid,
138+
]
139+
)
140+
141+
sql = """
142+
INSERT INTO spp_cycle_membership
143+
(partner_id, cycle_id, state, enrollment_date,
144+
create_uid, write_uid, create_date, write_date)
145+
VALUES {}
146+
ON CONFLICT (partner_id, cycle_id) DO NOTHING
147+
""".format( # noqa: S608 # nosec B608
148+
", ".join(values)
149+
)
150+
cr.execute(sql, params)
151+
total_inserted += cr.rowcount
152+
153+
_logger.info(
154+
"Bulk inserted %d cycle memberships (%d skipped as duplicates)",
155+
total_inserted,
156+
len(vals_list) - total_inserted,
157+
)
158+
return total_inserted
159+
90160
def unlink(self):
91161
if not self:
92162
return

spp_programs/models/managers/cycle_manager_base.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -835,25 +835,26 @@ 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+
:return: Integer - count of inserted members
842+
"""
843+
today = fields.Date.today()
844+
vals_list = [
845+
{
846+
"partner_id": partner_id,
847+
"cycle_id": cycle.id,
848+
"enrollment_date": today,
849+
"state": state,
850+
}
851+
for partner_id in beneficiaries
852+
]
853+
self.env["spp.cycle.membership"].bulk_create_memberships(vals_list, skip_duplicates=True)
854+
855+
# Raw SQL bypasses the ORM cache — invalidate so subsequent reads
856+
# (e.g. cycle.cycle_membership_ids) reflect the new rows.
857+
cycle.invalidate_recordset(["cycle_membership_ids"])
857858

858859
if do_count:
859860
# 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: 65 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,53 @@ 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+
for i in range(0, len(vals_list), chunk_size):
409+
batch = vals_list[i : i + chunk_size]
410+
values = []
411+
params = []
412+
for v in batch:
413+
values.append("(%s, %s, %s, %s, %s, now(), now())")
414+
params.extend(
415+
[
416+
v["partner_id"],
417+
v["program_id"],
418+
v.get("state", "draft"),
419+
uid,
420+
uid,
421+
]
422+
)
423+
424+
sql = """
425+
INSERT INTO spp_program_membership
426+
(partner_id, program_id, state,
427+
create_uid, write_uid, create_date, write_date)
428+
VALUES {}
429+
ON CONFLICT (partner_id, program_id) DO NOTHING
430+
""".format( # noqa: S608 # nosec B608
431+
", ".join(values)
432+
)
433+
cr.execute(sql, params)
434+
total_inserted += cr.rowcount
435+
436+
_logger.info(
437+
"Bulk inserted %d program memberships (%d skipped as duplicates)",
438+
total_inserted,
439+
len(vals_list) - total_inserted,
440+
)
441+
return total_inserted

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)