Skip to content

Commit 44f97b4

Browse files
committed
fix: 315764 handle create_rdp_and_start_dedup_core smoothly
1 parent b09f956 commit 44f97b4

6 files changed

Lines changed: 104 additions & 65 deletions

File tree

src/country_workspace/contrib/hope/push/orchestration.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
workflow_config_for_rdp,
4040
)
4141

42-
_DEDUP_CALLBACK_SIGN_KEY = "dedup_callback"
43-
_DEDUP_CALLBACK_MAX_AGE = 60 * 60 * 96 # 96 hours
42+
DEDUP_CALLBACK_SALT = "dedup_callback"
43+
DEDUP_CALLBACK_MAX_AGE = 60 * 60 * 96 # 96 hours
4444

4545
logger = logging.getLogger(__name__)
4646

@@ -98,7 +98,7 @@ def _build_dedup_callback_url(rdp_id: int, job_id: int) -> str:
9898
"""Build an absolute, signed callback URL for the dedup engine to call when dedup finishes."""
9999
token = signing.dumps(
100100
{"rdp_id": rdp_id, "job_id": job_id},
101-
key=_DEDUP_CALLBACK_SIGN_KEY,
101+
salt=DEDUP_CALLBACK_SALT,
102102
)
103103
path = reverse("dedup_callback", kwargs={"signed_token": token})
104104
base = config.APP_BASE_URL.rstrip("/")
@@ -120,21 +120,35 @@ def create_rdp_and_start_dedup_core(job: AsyncJob) -> dict[str, Any]:
120120
if rdp is None:
121121
raise HopePushError({"errors": ["RDP: could not claim deduplication set."], "rdp_id": rdp_id})
122122

123-
job.refresh_from_db()
123+
awaiting_callback = False
124+
try:
125+
job.refresh_from_db()
124126

125-
callback_url = _build_dedup_callback_url(rdp_id=rdp_id, job_id=job.pk)
126-
processor = DedupProcessor(rdp)
127-
processor.run(notification_url=callback_url)
127+
callback_url = _build_dedup_callback_url(rdp_id=rdp_id, job_id=job.pk)
128+
processor = DedupProcessor(rdp)
129+
processor.run(notification_url=callback_url)
128130

129-
if processor.has_errors:
130-
raise HopePushError({**processor.total, "rdp_id": rdp_id})
131+
if processor.has_errors:
132+
with transaction.atomic():
133+
locked = lock_rdp_for_update(pk=rdp_id)
134+
set_rdp_push_status(
135+
rdp=locked,
136+
status=Rdp.PushStatus.FAILURE,
137+
hope_rdi_id="N/A",
138+
)
139+
raise HopePushError({**processor.total, "rdp_id": rdp_id})
131140

132-
with transaction.atomic():
133-
locked = lock_rdp_for_update(pk=rdp_id)
134-
locked.status = Rdp.PushStatus.DEDUP_PENDING
135-
locked.save(update_fields=["status"])
141+
with transaction.atomic():
142+
locked = lock_rdp_for_update(pk=rdp_id)
143+
locked.status = Rdp.PushStatus.DEDUP_PENDING
144+
locked.save(update_fields=["status"])
136145

137-
return {**create_result, "dedup_pending": True}
146+
awaiting_callback = True
147+
return {**create_result, "dedup_pending": True}
148+
finally:
149+
# Keep the lock only while waiting for the HDE callback; release on any failure.
150+
if not awaiting_callback:
151+
release_rdp_dedup_settings_lock(rdp_id=rdp_id)
138152

139153

140154
def create_and_push_rdp_core(job: AsyncJob) -> dict[str, Any]:
@@ -170,11 +184,11 @@ def _lock_and_fail_rdp(rdp_id: int, *, reason: str) -> None:
170184
)
171185

172186

173-
def _handle_deduplicated(rdp: Rdp, rdp_id: int, findings_count: int) -> None:
187+
def _handle_deduplicated(rdp: Rdp, rdp_id: int, job_id: int, findings_count: int) -> None:
174188
try:
175-
origin_job = AsyncJob.objects.filter(rdp_id=rdp_id).latest("id")
189+
origin_job = AsyncJob.objects.get(pk=job_id, rdp_id=rdp_id)
176190
except AsyncJob.DoesNotExist:
177-
_lock_and_fail_rdp(rdp_id, reason="missing origin AsyncJob for threshold config")
191+
_lock_and_fail_rdp(rdp_id, reason=f"missing origin AsyncJob id={job_id} for threshold config")
178192
return
179193

180194
max_findings_percent: int = origin_job.config.get("max_dedup_findings_percent", 0)
@@ -233,7 +247,7 @@ def _handle_deduplicated(rdp: Rdp, rdp_id: int, findings_count: int) -> None:
233247
)
234248

235249

236-
def dedup_callback_handle(rdp_id: int) -> None:
250+
def dedup_callback_handle(rdp_id: int, job_id: int) -> None:
237251
"""Handle a dedup engine callback for the given RDP.
238252
239253
Fetches the current deduplication set state from the engine. Only acts on
@@ -265,8 +279,9 @@ def dedup_callback_handle(rdp_id: int) -> None:
265279
return
266280

267281
logger.info(
268-
"dedup_callback_handle: rdp_id=%s deduplication_set_id=%s; fetching remote status",
282+
"dedup_callback_handle: rdp_id=%s job_id=%s deduplication_set_id=%s; fetching remote status",
269283
rdp_id,
284+
job_id,
270285
rdp.deduplication_set_id,
271286
)
272287

@@ -299,7 +314,7 @@ def dedup_callback_handle(rdp_id: int) -> None:
299314
if dedup_state in terminal_failure_states:
300315
_lock_and_fail_rdp(rdp_id, reason=f"dedup engine state {dedup_state}")
301316
elif dedup_state == DeduplicationSetState.DEDUPLICATED:
302-
_handle_deduplicated(rdp, rdp_id, status.findings_count)
317+
_handle_deduplicated(rdp, rdp_id, job_id, status.findings_count)
303318
else:
304319
logger.info(
305320
"dedup_callback_handle: rdp_id=%s intermediate state %s; waiting for next callback",

src/country_workspace/contrib/hope/views.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from django.views import View
66

77
from country_workspace.contrib.hope.push.orchestration import (
8-
_DEDUP_CALLBACK_MAX_AGE,
9-
_DEDUP_CALLBACK_SIGN_KEY,
8+
DEDUP_CALLBACK_MAX_AGE,
9+
DEDUP_CALLBACK_SALT,
1010
dedup_callback_handle,
1111
)
1212

@@ -26,8 +26,8 @@ def get(self, request: HttpRequest, signed_token: str) -> HttpResponse:
2626
try:
2727
data = signing.loads(
2828
signed_token,
29-
key=_DEDUP_CALLBACK_SIGN_KEY,
30-
max_age=_DEDUP_CALLBACK_MAX_AGE,
29+
salt=DEDUP_CALLBACK_SALT,
30+
max_age=DEDUP_CALLBACK_MAX_AGE,
3131
)
3232
except signing.SignatureExpired:
3333
logger.warning("dedup_callback: signed token expired for token prefix=%s", signed_token[:12])
@@ -37,13 +37,14 @@ def get(self, request: HttpRequest, signed_token: str) -> HttpResponse:
3737
return HttpResponseForbidden("Invalid token.")
3838

3939
rdp_id = data.get("rdp_id")
40-
if not isinstance(rdp_id, int):
41-
logger.warning("dedup_callback: missing or invalid rdp_id in token data=%r", data)
40+
job_id = data.get("job_id")
41+
if not isinstance(rdp_id, int) or not isinstance(job_id, int):
42+
logger.warning("dedup_callback: missing or invalid token payload data=%r", data)
4243
return HttpResponseForbidden("Invalid token payload.")
4344

4445
try:
45-
dedup_callback_handle(rdp_id=rdp_id)
46+
dedup_callback_handle(rdp_id=rdp_id, job_id=job_id)
4647
except Exception:
47-
logger.exception("dedup_callback: unhandled error while handling rdp_id=%s", rdp_id)
48+
logger.exception("dedup_callback: unhandled error while handling rdp_id=%s job_id=%s", rdp_id, job_id)
4849

4950
return HttpResponse(status=200)

src/country_workspace/migrations/0058_rdp_dedup_pending_status.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import country_workspace.models.rdp
12
from django.db import migrations, models
23

34

@@ -12,13 +13,7 @@ class Migration(migrations.Migration):
1213
name="status",
1314
field=models.CharField(
1415
blank=True,
15-
choices=[
16-
("PENDING", "Pending"),
17-
("DEDUP_PENDING", "Awaiting deduplication"),
18-
("SUCCESS", "Success"),
19-
("FAILURE", "Failure"),
20-
("CANCELLED", "Cancelled"),
21-
],
16+
choices=country_workspace.models.rdp.get_rdp_status_choices,
2217
default="PENDING",
2318
max_length=15,
2419
),

src/country_workspace/models/rdp.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
from .user import User
77

88

9+
# Do not rename: migrations reference this callable by dotted path.
10+
def get_rdp_status_choices() -> list[tuple[str, str]]:
11+
return list(Rdp.PushStatus.choices)
12+
13+
914
class Rdp(BaseModel):
1015
"""Represents a Registration Data Push (RDP) object in the system."""
1116

@@ -22,7 +27,7 @@ class OperationAction(models.TextChoices):
2227
country_office = models.ForeignKey("Office", on_delete=models.CASCADE, related_name="%(class)ss")
2328
program = models.ForeignKey("Program", on_delete=models.CASCADE, related_name="%(class)ss")
2429
name = models.CharField(max_length=255, blank=True, null=True)
25-
status = models.CharField(max_length=15, choices=PushStatus.choices, default=PushStatus.PENDING, blank=True)
30+
status = models.CharField(max_length=15, choices=get_rdp_status_choices, default=PushStatus.PENDING, blank=True)
2631
hope_rdi_id = models.CharField(
2732
max_length=200, null=True, editable=False, help_text=_("RDI unique ID within the HOPE core.")
2833
)

0 commit comments

Comments
 (0)