Skip to content

Commit 32a5c78

Browse files
Merge pull request #188 from OpenSPP/fix/cycle-stale-lock-recovery
fix(spp_programs): release stuck cycle/program lock when async pipeline fails
2 parents 30f6364 + da15f26 commit 32a5c78

14 files changed

Lines changed: 571 additions & 21 deletions

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.11",
7+
"version": "19.0.2.1.0",
88
"sequence": 1,
99
"author": "OpenSPP.org",
1010
"website": "https://github.com/OpenSPP/OpenSPP2",

spp_programs/models/cycle.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,27 @@ def _get_related_job_domain(self):
10621062
related_jobs = jobs.filtered(lambda r: self in r.args[0])
10631063
return [("id", "in", related_jobs.ids)]
10641064

1065+
def action_force_unlock(self):
1066+
"""Manager-only escape hatch: clear a stuck "Operation in progress" lock.
1067+
1068+
Use when an async pipeline (entitlement processing, payment prep, etc.)
1069+
died without firing its on_done/on_error callback — for example after
1070+
a hard server restart or before this fix was deployed. Posts an audit
1071+
line to chatter so admins can see who unstuck the cycle.
1072+
"""
1073+
for rec in self:
1074+
if not rec.is_locked:
1075+
continue
1076+
previous_reason = rec.locked_reason
1077+
rec.write({"is_locked": False, "locked_reason": False})
1078+
rec.message_post(
1079+
body=_(
1080+
"Lock manually cleared by %(user)s. Previous reason: %(reason)s",
1081+
user=self.env.user.display_name,
1082+
reason=previous_reason or _("(none)"),
1083+
)
1084+
)
1085+
10651086
def unlink(self):
10661087
# Admin also not able to delete the cycle bcz of beneficiaries mapped
10671088
# So this function common for who are all having delete access.

spp_programs/models/managers/cycle_manager_base.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -322,13 +322,24 @@ def mark_import_as_done(self, cycle, msg):
322322
:return:
323323
"""
324324
self.ensure_one()
325-
cycle.is_locked = False
326-
cycle.locked_reason = None
327-
cycle.message_post(body=msg)
325+
cycle.write({"is_locked": False, "locked_reason": False})
326+
try:
327+
cycle.message_post(body=msg)
328+
except Exception:
329+
_logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
328330

329331
# Refresh statistics after bulk operations
330332
cycle.refresh_statistics()
331333

334+
def mark_import_as_failed(self, cycle, msg):
335+
"""Run via on_error() when async beneficiary import fails."""
336+
self.ensure_one()
337+
cycle.write({"is_locked": False, "locked_reason": False})
338+
try:
339+
cycle.message_post(body=msg)
340+
except Exception:
341+
_logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
342+
332343
def mark_prepare_entitlement_as_done(self, cycle, msg):
333344
"""Complete the preparation of entitlements.
334345
Base :meth:`mark_prepare_entitlement_as_done`.
@@ -340,13 +351,24 @@ def mark_prepare_entitlement_as_done(self, cycle, msg):
340351
:return:
341352
"""
342353
self.ensure_one()
343-
cycle.is_locked = False
344-
cycle.locked_reason = None
345-
cycle.message_post(body=msg)
354+
cycle.write({"is_locked": False, "locked_reason": False})
355+
try:
356+
cycle.message_post(body=msg)
357+
except Exception:
358+
_logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
346359

347360
# Update Statistics
348361
cycle._compute_entitlements_count()
349362

363+
def mark_prepare_entitlement_as_failed(self, cycle, msg):
364+
"""Run via on_error() when async entitlement preparation fails."""
365+
self.ensure_one()
366+
cycle.write({"is_locked": False, "locked_reason": False})
367+
try:
368+
cycle.message_post(body=msg)
369+
except Exception:
370+
_logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
371+
350372
def mark_check_eligibility_as_done(self, cycle):
351373
"""Complete the enrollment of eligible beneficiaries.
352374
Base :meth:`mark_check_eligibility_as_done`.
@@ -356,13 +378,25 @@ def mark_check_eligibility_as_done(self, cycle):
356378
:param cycle: A recordset of cycle
357379
:return:
358380
"""
359-
cycle.is_locked = False
360-
cycle.locked_reason = None
361-
cycle.message_post(body=_("Eligibility check finished."))
381+
self.ensure_one()
382+
cycle.write({"is_locked": False, "locked_reason": False})
383+
try:
384+
cycle.message_post(body=_("Eligibility check finished."))
385+
except Exception:
386+
_logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
362387

363388
# Compute Statistics
364389
cycle._compute_members_count()
365390

391+
def mark_check_eligibility_as_failed(self, cycle):
392+
"""Run via on_error() when async eligibility check fails."""
393+
self.ensure_one()
394+
cycle.write({"is_locked": False, "locked_reason": False})
395+
try:
396+
cycle.message_post(body=_("Eligibility check failed."))
397+
except Exception:
398+
_logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
399+
366400

367401
class DefaultCycleManager(models.Model):
368402
_name = "spp.cycle.manager.default"
@@ -535,6 +569,7 @@ def _check_eligibility_async(self, cycle, beneficiaries_count):
535569
)
536570
main_job = group(*jobs)
537571
main_job.on_done(self.delayable(channel="statistics_refresh").mark_check_eligibility_as_done(cycle))
572+
main_job.on_error(self.delayable(channel="statistics_refresh").mark_check_eligibility_as_failed(cycle))
538573
main_job.delay()
539574

540575
def _check_eligibility(
@@ -624,6 +659,11 @@ def _prepare_entitlements_async(self, cycle, beneficiaries_count):
624659
cycle, _("Entitlement Ready.")
625660
)
626661
)
662+
main_job.on_error(
663+
self.delayable(channel="statistics_refresh").mark_prepare_entitlement_as_failed(
664+
cycle, _("Entitlement preparation failed.")
665+
)
666+
)
627667
main_job.delay()
628668

629669
def _prepare_entitlements(self, cycle, offset=0, limit=None, min_id=None, max_id=None, do_count=False):
@@ -870,6 +910,9 @@ def _add_beneficiaries_async(self, cycle, beneficiaries, state):
870910
main_job.on_done(
871911
self.delayable(channel="statistics_refresh").mark_import_as_done(cycle, _("Beneficiary import finished."))
872912
)
913+
main_job.on_error(
914+
self.delayable(channel="statistics_refresh").mark_import_as_failed(cycle, _("Beneficiary import failed."))
915+
)
873916
main_job.delay()
874917

875918
def _add_beneficiaries(self, cycle, beneficiaries, state="draft", do_count=False):

spp_programs/models/managers/entitlement_manager_base.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements):
9595
)
9696
main_job = group(*jobs)
9797
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Set to Pending Validation.")))
98+
main_job.on_error(
99+
self.delayable().mark_job_as_failed(cycle, _("Setting entitlements to pending validation failed."))
100+
)
98101
main_job.delay()
99102

100103
def _set_pending_validation_entitlements(self, entitlements):
@@ -146,6 +149,9 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count):
146149
)
147150
main_job = group(*jobs)
148151
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
152+
main_job.on_error(
153+
self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
154+
)
149155
main_job.delay()
150156

151157
def _validate_entitlements(self, entitlements):
@@ -210,6 +216,7 @@ def _cancel_entitlements_async(self, cycle, entitlements, entitlements_count):
210216
)
211217
main_job = group(*jobs)
212218
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Cancelled.")))
219+
main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Cancelling entitlements failed.")))
213220
main_job.delay()
214221

215222
def _cancel_entitlements(self, entitlements):
@@ -233,9 +240,30 @@ def mark_job_as_done(self, cycle, msg):
233240
:return:
234241
"""
235242
self.ensure_one()
236-
cycle.is_locked = False
237-
cycle.locked_reason = None
238-
cycle.message_post(body=msg)
243+
# Clear the lock first so a chatter-side failure can't leave the
244+
# cycle stuck with "Operation in progress".
245+
cycle.write({"is_locked": False, "locked_reason": False})
246+
try:
247+
cycle.message_post(body=msg)
248+
except Exception:
249+
_logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
250+
251+
def mark_job_as_failed(self, cycle, msg):
252+
"""Run via on_error() when the async pipeline fails.
253+
254+
Clears the cycle lock and posts a failure note to chatter so the
255+
user understands the operation finished without success — instead
256+
of the lock remaining set indefinitely (the bug this fix targets).
257+
258+
:param cycle: A recordset of cycle
259+
:param msg: A string to be posted in the chatter
260+
"""
261+
self.ensure_one()
262+
cycle.write({"is_locked": False, "locked_reason": False})
263+
try:
264+
cycle.message_post(body=msg)
265+
except Exception:
266+
_logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
239267

240268
def open_entitlements_form(self, cycle):
241269
"""

spp_programs/models/managers/entitlement_manager_cash.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count):
326326
)
327327
main_job = group(*jobs)
328328
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
329+
main_job.on_error(
330+
self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
331+
)
329332
main_job.delay()
330333

331334
def _validate_entitlements(self, cycle, entitlements):

spp_programs/models/managers/entitlement_manager_inkind.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,9 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements_count):
222222
)
223223
main_job = group(*jobs)
224224
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Set to Pending Validation.")))
225+
main_job.on_error(
226+
self.delayable().mark_job_as_failed(cycle, _("Setting entitlements to pending validation failed."))
227+
)
225228
main_job.delay()
226229

227230
def _set_pending_validation_entitlements(self, cycle, offset=0, limit=None):
@@ -324,6 +327,9 @@ def _validate_entitlements_async(self, cycle, entitlements_count):
324327
)
325328
main_job = group(*jobs)
326329
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Entitlements Validated and Approved.")))
330+
main_job.on_error(
331+
self.delayable().mark_job_as_failed(cycle, _("Validation and approval of entitlements failed."))
332+
)
327333
main_job.delay()
328334

329335
def _validate_entitlements(self, cycle, offset=0, limit=None):

spp_programs/models/managers/payment_manager.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,20 @@ def mark_job_as_done(self, cycle, msg):
7171
:return:
7272
"""
7373
self.ensure_one()
74-
cycle.is_locked = False
75-
cycle.locked_reason = None
76-
cycle.message_post(body=msg)
74+
cycle.write({"is_locked": False, "locked_reason": False})
75+
try:
76+
cycle.message_post(body=msg)
77+
except Exception:
78+
_logger.exception("Failed to post completion chatter on cycle %s", cycle.id)
79+
80+
def mark_job_as_failed(self, cycle, msg):
81+
"""Run via on_error() when the async payment pipeline fails."""
82+
self.ensure_one()
83+
cycle.write({"is_locked": False, "locked_reason": False})
84+
try:
85+
cycle.message_post(body=msg)
86+
except Exception:
87+
_logger.exception("Failed to post failure chatter on cycle %s", cycle.id)
7788

7889

7990
class DefaultFilePaymentManager(models.Model):
@@ -278,6 +289,7 @@ def _prepare_payments_async(self, cycle, entitlements, entitlements_count):
278289
]
279290
main_job = group(*jobs)
280291
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Prepared payments.")))
292+
main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Preparing payments failed.")))
281293
main_job.delay()
282294

283295
def send_payments(self, batches):
@@ -392,6 +404,7 @@ def _send_payments_async(self, cycle, batches):
392404
]
393405
main_job = group(*jobs)
394406
main_job.on_done(self.delayable().mark_job_as_done(cycle, _("Send payments completed.")))
407+
main_job.on_error(self.delayable().mark_job_as_failed(cycle, _("Sending payments failed.")))
395408
main_job.delay()
396409

397410
@api.model

spp_programs/models/managers/program_manager.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,26 @@ def mark_enroll_eligible_as_done(self):
7373
:return:
7474
"""
7575
self.ensure_one()
76-
self.program_id.is_locked = False
77-
self.program_id.locked_reason = None
78-
self.program_id.message_post(body=_("Eligibility check finished."))
76+
program = self.program_id
77+
program.write({"is_locked": False, "locked_reason": False})
78+
try:
79+
program.message_post(body=_("Eligibility check finished."))
80+
except Exception:
81+
_logger.exception("Failed to post completion chatter on program %s", program.id)
7982

8083
# Compute Statistics
81-
self.program_id._compute_eligible_beneficiary_count()
82-
self.program_id._compute_beneficiary_count()
84+
program._compute_eligible_beneficiary_count()
85+
program._compute_beneficiary_count()
86+
87+
def mark_enroll_eligible_as_failed(self):
88+
"""Run via on_error() when async eligibility enrollment fails."""
89+
self.ensure_one()
90+
program = self.program_id
91+
program.write({"is_locked": False, "locked_reason": False})
92+
try:
93+
program.message_post(body=_("Eligibility check failed."))
94+
except Exception:
95+
_logger.exception("Failed to post failure chatter on program %s", program.id)
8396

8497

8598
class DefaultProgramManager(models.Model):
@@ -215,6 +228,7 @@ def _enroll_eligible_registrants_async(self, states, members_count):
215228
)
216229
main_job = group(*jobs)
217230
main_job.on_done(self.delayable(channel="statistics_refresh").mark_enroll_eligible_as_done())
231+
main_job.on_error(self.delayable(channel="statistics_refresh").mark_enroll_eligible_as_failed())
218232
main_job.delay()
219233

220234
def _enroll_eligible_registrants(self, states, offset=0, limit=None, min_id=None, max_id=None, do_count=False):

spp_programs/models/programs.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,25 @@ def _get_related_job_domain(self):
725725
related_jobs = jobs.filtered(lambda r: self in r.records.program_id)
726726
return [("id", "in", related_jobs.ids)]
727727

728+
def action_force_unlock(self):
729+
"""Manager-only escape hatch: clear a stuck "Operation in progress" lock.
730+
731+
Use when an async pipeline died without firing its on_done/on_error
732+
callback. Posts an audit line to chatter for traceability.
733+
"""
734+
for rec in self:
735+
if not rec.is_locked:
736+
continue
737+
previous_reason = rec.locked_reason
738+
rec.write({"is_locked": False, "locked_reason": False})
739+
rec.message_post(
740+
body=_(
741+
"Lock manually cleared by %(user)s. Previous reason: %(reason)s",
742+
user=self.env.user.display_name,
743+
reason=previous_reason or _("(none)"),
744+
)
745+
)
746+
728747
@api.constrains(
729748
"entitlement_manager_ids",
730749
"program_manager_ids",

spp_programs/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@
3737
from . import test_keyset_pagination
3838
from . import test_canary_patterns
3939
from . import test_concurrency
40+
from . import test_async_lock_recovery

0 commit comments

Comments
 (0)