Skip to content

perf: bulk membership creation with INSERT ON CONFLICT DO NOTHING#149

Open
kneckinator wants to merge 2 commits into19.0from
worktree-perf+phase7-bulk-membership
Open

perf: bulk membership creation with INSERT ON CONFLICT DO NOTHING#149
kneckinator wants to merge 2 commits into19.0from
worktree-perf+phase7-bulk-membership

Conversation

@kneckinator
Copy link
Copy Markdown
Contributor

@kneckinator kneckinator commented Apr 3, 2026

Summary

  • Add skip_duplicates=True mode to bulk_create_memberships() on both spp.program.membership and spp.cycle.membership
  • When enabled, uses raw SQL INSERT ... ON CONFLICT (partner_id, program_id/cycle_id) DO NOTHING to silently skip duplicates instead of raising IntegrityError or doing per-record search() checks
  • Returns the count of actually inserted rows via cursor.rowcount
  • Update _import_registrants() and _add_beneficiaries() callers to use the new path
  • Add ORM cache invalidation after raw SQL inserts so subsequent recordset reads reflect new rows

Changes

  • program_membership.py: Add skip_duplicates param + _bulk_insert_on_conflict() helper using raw SQL
  • cycle_membership.py: Same pattern for cycle memberships
  • eligibility_manager.py: Replace Command.create() + program.update() with bulk_create_memberships(skip_duplicates=True)
  • cycle_manager_base.py: Replace [0, 0, {}] tuples + cycle.update() with bulk_create_memberships(skip_duplicates=True)
  • test_bulk_membership.py (new): 13 tests covering insert, duplicate skipping, empty lists, chunking, ORM fallback, and caller integration

Context

Phase 7 of 9 in the spp_programs performance optimization effort. Depends on phase 1 - #116 being merged.

Test plan

  • ./scripts/test_single_module.sh spp_programs — 591 tests, 0 failures
  • pre-commit run --files <changed_files> — all checks pass
  • For async operations: test via UI with ODOO_INIT_MODULES=spp_programs docker compose --profile ui up -d

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.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces high-performance bulk membership creation for both programs and cycles by implementing raw SQL INSERT statements with ON CONFLICT DO NOTHING to efficiently handle duplicates. The cycle and eligibility managers have been updated to utilize these new methods, and a comprehensive test suite has been added to verify the bulk insertion logic. The review feedback highlights the need to manually handle computed fields like enrollment_date when bypassing the ORM, suggests optimizing performance by moving date function calls outside of loops, and recommends using the @api.model_create_multi decorator for consistency with Odoo's batch processing patterns.

Comment on lines +408 to +432
for i in range(0, len(vals_list), chunk_size):
batch = vals_list[i : i + chunk_size]
values = []
params = []
for v in batch:
values.append("(%s, %s, %s, %s, %s, now(), now())")
params.extend(
[
v["partner_id"],
v["program_id"],
v.get("state", "draft"),
uid,
uid,
]
)

sql = """
INSERT INTO spp_program_membership
(partner_id, program_id, state,
create_uid, write_uid, create_date, write_date)
VALUES {}
ON CONFLICT (partner_id, program_id) DO NOTHING
""".format( # noqa: S608 # nosec B608
", ".join(values)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The enrollment_date field in spp.program.membership is a stored computed field that depends on the state. Since this raw SQL insert bypasses the ORM, the field will not be populated for records inserted with state='enrolled'. To ensure data integrity, you should include enrollment_date in the SQL insert and set it to the current timestamp when the state is enrolled.

        for i in range(0, len(vals_list), chunk_size):
            batch = vals_list[i : i + chunk_size]
            values = []
            params = []
            for v in batch:
                state = v.get("state", "draft")
                enrollment_date = fields.Datetime.now() if state == "enrolled" else None
                values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
                params.extend(
                    [
                        v["partner_id"],
                        v["program_id"],
                        state,
                        enrollment_date,
                        uid,
                        uid,
                    ]
                )

            sql = """
                INSERT INTO spp_program_membership
                    (partner_id, program_id, state, enrollment_date,
                     create_uid, write_uid, create_date, write_date)
                VALUES {}
                ON CONFLICT (partner_id, program_id) DO NOTHING
            """.format(
                ", ".join(values)
            )

},
}

@api.model
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is recommended to use the @api.model_create_multi decorator for methods that accept a list of dictionaries (vals_list). This ensures that the method is always called with a list, providing better consistency with Odoo's standard batch processing patterns.

Suggested change
@api.model
@api.model_create_multi

Comment on lines +122 to +139
total_inserted = 0

for i in range(0, len(vals_list), chunk_size):
batch = vals_list[i : i + chunk_size]
values = []
params = []
for v in batch:
values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
params.extend(
[
v["partner_id"],
v["cycle_id"],
v.get("state", "draft"),
v.get("enrollment_date", fields.Date.today()),
uid,
uid,
]
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling fields.Date.today() inside the loop for every record is inefficient, especially for large batches. It should be called once outside the loop and stored in a variable.

Suggested change
total_inserted = 0
for i in range(0, len(vals_list), chunk_size):
batch = vals_list[i : i + chunk_size]
values = []
params = []
for v in batch:
values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
params.extend(
[
v["partner_id"],
v["cycle_id"],
v.get("state", "draft"),
v.get("enrollment_date", fields.Date.today()),
uid,
uid,
]
)
total_inserted = 0
today = fields.Date.today()
for i in range(0, len(vals_list), chunk_size):
batch = vals_list[i : i + chunk_size]
values = []
params = []
for v in batch:
values.append("(%s, %s, %s, %s, %s, %s, now(), now())")
params.extend(
[
v["partner_id"],
v["cycle_id"],
v.get("state", "draft"),
v.get("enrollment_date", today),
uid,
uid,
]
)


@api.model_create_multi
def bulk_create_memberships(self, vals_list, chunk_size=1000):
@api.model
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

It is recommended to use the @api.model_create_multi decorator for methods that accept a list of dictionaries (vals_list). This ensures that the method is always called with a list, providing better consistency with Odoo's standard batch processing patterns.

Suggested change
@api.model
@api.model_create_multi

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.11%. Comparing base (f738582) to head (5f8bf9b).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #149      +/-   ##
==========================================
+ Coverage   71.06%   71.11%   +0.04%     
==========================================
  Files         925      925              
  Lines       54704    54774      +70     
==========================================
+ Hits        38876    38952      +76     
+ Misses      15828    15822       -6     
Flag Coverage Δ
spp_api_v2 80.10% <ø> (ø)
spp_api_v2_change_request 66.85% <ø> (ø)
spp_api_v2_cycles 71.12% <ø> (ø)
spp_api_v2_data 64.41% <ø> (ø)
spp_api_v2_entitlements 70.19% <ø> (ø)
spp_api_v2_gis 71.52% <ø> (ø)
spp_api_v2_products 66.27% <ø> (ø)
spp_api_v2_service_points 70.94% <ø> (ø)
spp_api_v2_simulation 71.12% <ø> (ø)
spp_api_v2_vocabulary 57.26% <ø> (ø)
spp_audit 64.44% <ø> (+0.60%) ⬆️
spp_base_common 90.26% <ø> (ø)
spp_case_entitlements 97.61% <ø> (ø)
spp_case_programs 97.14% <ø> (ø)
spp_cel_event 85.11% <ø> (ø)
spp_claim_169 58.11% <ø> (ø)
spp_dci_client_dr 55.87% <ø> (ø)
spp_dci_client_ibr 60.17% <ø> (ø)
spp_programs 62.63% <100.00%> (+0.39%) ⬆️
spp_security 66.66% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_programs/__manifest__.py 0.00% <ø> (ø)
spp_programs/models/cycle_membership.py 75.00% <100.00%> (+14.13%) ⬆️
spp_programs/models/managers/cycle_manager_base.py 55.12% <100.00%> (ø)
...pp_programs/models/managers/eligibility_manager.py 75.00% <100.00%> (-0.21%) ⬇️
spp_programs/models/program_membership.py 54.23% <100.00%> (+12.67%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Set enrollment_date to current timestamp when state is 'enrolled' in
the program membership SQL insert (computed field not triggered by raw
SQL). Hoist fields.Date.today() outside the loop in cycle membership
to avoid repeated calls per record.
Copy link
Copy Markdown
Member

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kneckinator I think if audit rules are defined using spp_audit for spp.program.membership and spp.cycle.membership are added, the raw SQL will bypass it entirely. Though I think the performance gain is significant, I am just concerned about the audit logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants