Skip to content

Commit 81ced0d

Browse files
rtibblesbotclaude
andcommitted
Normalize the WorkManager request id to Kolibri's supervisor_id form
The task worker stamped a job's supervisor_id with the WorkManager request id in its canonical java.util.UUID.toString() (dashed) form, and the reconciler built its live set the same way. But Kolibri's morango UUIDField normalizes every stored supervisor_id to the dashless uuid4().hex form, and the ownership fence compares stored-vs-expected owner as plain strings. So after the claim write, the job's own completion/worker-info writes compared unequal to the stored owner, were discarded as disowned, and the job was stranded in RUNNING forever — the first-run setup task never completed, so onboarding never reached the library (the Android smoke test's "Welcome to Kolibri!" assertion). Convert the request id to the dashless hex form via a shared supervisor_id_from_request helper at both use sites (the ownership claim and the reconcile live set) so they agree with each other and with what morango stores. Add an end-to-end lifecycle test that runs a job through the real storage and asserts it reaches COMPLETED with ownership cleared. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b920978 commit 81ced0d

7 files changed

Lines changed: 83 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Canonical supervisor-id form for the Android task layer."""
2+
3+
import uuid
4+
5+
6+
def supervisor_id_from_request(request_id):
7+
"""
8+
Convert a WorkManager request id to the form Kolibri stores as supervisor_id.
9+
10+
WorkManager gives dashed UUIDs; morango stores supervisor_id as dashless hex.
11+
Both the claim (taskworker) and the reconcile live set (task_reconciler) must
12+
normalize here, or a completion write looks disowned and strands the job in
13+
RUNNING.
14+
"""
15+
return uuid.UUID(str(request_id)).hex

platforms/android/app/src/main/python/task_reconciler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88

99
from java import jclass
10+
from task_identity import supervisor_id_from_request
1011

1112
from kolibri.core.tasks.job import State
1213
from kolibri.core.tasks.main import job_storage
@@ -26,7 +27,7 @@ def _extract_work_ids(work_infos):
2627
job_ids = set()
2728
request_ids = set()
2829
for work_info in work_infos:
29-
request_ids.add(str(work_info.getId()))
30+
request_ids.add(supervisor_id_from_request(work_info.getId()))
3031
tags = work_info.getTags()
3132
# Tags mix worker-class FQNs with our own; an explicit prefix identifies
3233
# ours rather than excluding known patterns.

platforms/android/app/src/main/python/taskworker.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import os
33
import threading
44

5+
from task_identity import supervisor_id_from_request
6+
57
from kolibri.core.tasks.worker import execute_job as kolibri_execute_job
68

79
logger = logging.getLogger(__name__)
@@ -31,7 +33,7 @@ def execute_job(job_id, request_id):
3133
worker_process=str(os.getpid()),
3234
worker_thread=str(threading.get_ident()),
3335
worker_extra=str(request_id),
34-
supervisor_id=str(request_id),
36+
supervisor_id=supervisor_id_from_request(request_id),
3537
)
3638
logger.info(
3739
"Completed Kolibri task worker for job {} (request {})".format(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
End-to-end: a job dispatched via taskworker with a real (dashed) request id must
3+
reach COMPLETED against unmocked storage, exercising the ownership fence that the
4+
unit tests cannot (see task_identity for why normalization is load-bearing).
5+
"""
6+
7+
from django.test import TestCase
8+
9+
from kolibri.core.tasks.job import Job
10+
from kolibri.core.tasks.job import State
11+
from kolibri.core.tasks.main import job_storage
12+
13+
14+
def _noop_task(**kwargs):
15+
return "ok"
16+
17+
18+
class TaskWorkerLifecycleTest(TestCase):
19+
databases = "__all__"
20+
21+
def test_execute_via_request_id_reaches_completed(self):
22+
import taskworker
23+
24+
job = Job(_noop_task)
25+
job_id = job_storage.enqueue_job(job)
26+
27+
# A canonical (dashed) java.util.UUID.toString() request id.
28+
taskworker.execute_job(job_id, "3f2504e0-4f89-41d3-9a0c-0305e82c3301")
29+
30+
orm_job = job_storage.get_orm_job(job_id)
31+
self.assertEqual(orm_job.state, State.COMPLETED)
32+
# Ownership is cleared on a terminal transition.
33+
self.assertIsNone(orm_job.supervisor_id)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from task_identity import supervisor_id_from_request
2+
3+
HEX = "3f2504e04f8941d39a0c0305e82c3301"
4+
5+
6+
def test_dashed_request_id_normalized_to_hex():
7+
assert (
8+
supervisor_id_from_request("3f2504e0-4f89-41d3-9a0c-0305e82c3301")
9+
== HEX
10+
)
11+
12+
13+
def test_dashless_request_id_is_idempotent():
14+
assert supervisor_id_from_request(HEX) == HEX

platforms/android/app/src/main/python/tests/test_task_reconciler.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ def _make_work_info(request_id, tags):
1414
def test_extract_work_ids_splits_job_ids_and_request_ids():
1515
work_infos = [
1616
_make_work_info(
17-
"req-a",
17+
"3f2504e0-4f89-41d3-9a0c-0305e82c3301",
1818
[
1919
"kolibri:job:job-a",
2020
"org.learningequality.Kolibri.task.TaskWorker",
2121
],
2222
),
2323
_make_work_info(
24-
"req-b",
24+
"5a3f8b2c-1d4e-4c6f-8a9b-0c1d2e3f4a5b",
2525
[
2626
"kolibri:job:job-b",
2727
"androidx.work.impl.workers.SomeWorker",
@@ -32,7 +32,11 @@ def test_extract_work_ids_splits_job_ids_and_request_ids():
3232
job_ids, request_ids = task_reconciler._extract_work_ids(work_infos)
3333

3434
assert job_ids == {"job-a", "job-b"}
35-
assert request_ids == {"req-a", "req-b"}
35+
# Request ids are normalized to the dashless form Kolibri stores as owner.
36+
assert request_ids == {
37+
"3f2504e04f8941d39a0c0305e82c3301",
38+
"5a3f8b2c1d4e4c6f8a9b0c1d2e3f4a5b",
39+
}
3640

3741

3842
def test_do_reconciliation_reconciles_stalled_jobs_first_with_live_set():

platforms/android/app/src/main/python/tests/test_taskworker.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,26 @@
22

33
import taskworker
44

5+
# A canonical (dashed) java.util.UUID.toString() request id and its dashless
6+
# hex form, which is what Kolibri stores and compares as the supervisor_id.
7+
REQUEST_ID = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
8+
SUPERVISOR_ID = "3f2504e04f8941d39a0c0305e82c3301"
9+
510

611
def test_execute_job_claims_ownership_with_request_id():
712
with mock.patch.object(taskworker, "kolibri_execute_job") as kej:
8-
taskworker.execute_job("job-1", "req-9")
13+
taskworker.execute_job("job-1", REQUEST_ID)
914
_, kwargs = kej.call_args
10-
assert kwargs["supervisor_id"] == "req-9"
15+
assert kwargs["supervisor_id"] == SUPERVISOR_ID
1116

1217

1318
def test_execute_job_returns_true_on_success():
1419
with mock.patch.object(taskworker, "kolibri_execute_job"):
15-
assert taskworker.execute_job("job-1", "req-9") is True
20+
assert taskworker.execute_job("job-1", REQUEST_ID) is True
1621

1722

1823
def test_execute_job_returns_false_on_error():
1924
with mock.patch.object(
2025
taskworker, "kolibri_execute_job", side_effect=RuntimeError("boom")
2126
):
22-
assert taskworker.execute_job("job-1", "req-9") is False
27+
assert taskworker.execute_job("job-1", REQUEST_ID) is False

0 commit comments

Comments
 (0)