Skip to content

Commit bcd5e77

Browse files
authored
Merge pull request #15051 from rtibblesbot/issue-15014-3d4576
Adopt supervisor ownership and reconciliation in the Android task layer
2 parents 5b6775f + 5000c5c commit bcd5e77

13 files changed

Lines changed: 331 additions & 16 deletions

File tree

.github/workflows/tox.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,35 @@ jobs:
385385
- name: Run tests
386386
working-directory: ${{ matrix.working-directory }}
387387
run: uv run python -O -m pytest
388+
# The Android host tests exercise the WorkManager task layer against real
389+
# Kolibri core (supervisor ownership, reconciliation), so they need Kolibri's
390+
# Django test settings and the kolibri/dist pythonpath. They live under the
391+
# Chaquopy source dir but can't use the member's own pytest config, so run
392+
# them from the repo root with the root pytest.ini. Pinned to Python 3.10 to
393+
# match Chaquopy's embedded runtime (app/build.gradle python { version }).
394+
android_platform_tests:
395+
name: Platform tests (kolibri-installer-android)
396+
needs: [pre_job, stage1_required_checks]
397+
if: ${{ needs.pre_job.outputs.should_skip != 'true' && needs.stage1_required_checks.result == 'success' }}
398+
runs-on: ubuntu-latest
399+
steps:
400+
- uses: actions/checkout@v7.0.0
401+
with:
402+
fetch-depth: 0
403+
- name: Set up uv
404+
uses: astral-sh/setup-uv@v8.2.0
405+
with:
406+
enable-cache: true
407+
cache-python: true
408+
- name: Install Python 3.10
409+
run: uv python install 3.10
410+
- name: Install dependencies
411+
# --all-packages keeps root kolibri's runtime + test deps in the shared
412+
# venv alongside the kolibri-installer-android member; the tests import
413+
# kolibri.core.tasks and run real jobs through morango storage.
414+
run: uv sync --group test --python 3.10 --all-packages
415+
- name: Run tests
416+
run: uv run python -O -m pytest platforms/android/app/src/main/python/tests -c pytest.ini --color=no
388417
# Single stable check for branch protection. Skipped matrix jobs don't
389418
# produce per-version check runs, so matrix-suffixed required checks hang
390419
# as "Expected" on PRs that skip the tests; require these jobs instead.
@@ -414,6 +443,7 @@ jobs:
414443
- sync_extras_plugin_tests
415444
- opensearch_plugin_tests
416445
- platform_tests
446+
- android_platform_tests
417447
if: always()
418448
runs-on: ubuntu-latest
419449
steps:

platforms/android/app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ android {
248248
main {
249249
python {
250250
srcDir "src/main/python"
251+
// Host-only pytest tests must not be bundled into the APK.
252+
exclude "tests/**"
251253
}
252254
}
253255
}

platforms/android/app/src/main/python/android_app_plugin/kolibri_plugin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from kolibri.core.device.hooks import GetOSUserHook
1111
from kolibri.core.tasks.hooks import JobHook
1212
from kolibri.core.tasks.job import Priority
13+
from kolibri.core.tasks.job import State
1314
from kolibri.plugins import KolibriPluginBase
1415
from kolibri.plugins.hooks import register_hook
1516

@@ -55,6 +56,9 @@ def schedule(
5556
job,
5657
orm_job,
5758
):
59+
self._enqueue_task(job, orm_job)
60+
61+
def _enqueue_task(self, job, orm_job):
5862
if orm_job.id:
5963
delay = 0
6064
if orm_job.scheduled_time:
@@ -85,6 +89,9 @@ def schedule(
8589
job.update_worker_info(extra=request_id)
8690

8791
def update(self, job, orm_job, state=None, **kwargs):
92+
if state == State.QUEUED:
93+
self._enqueue_task(job, orm_job)
94+
8895
currentLocale = Locale.getDefault().toLanguageTag()
8996

9097
status = job.status(currentLocale)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
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+
WorkManager gives dashed UUIDs; morango stores supervisor_id as dashless
9+
hex. Both the claim (taskworker) and the reconcile live set
10+
(task_reconciler) must normalize here, or a completion write looks disowned
11+
and strands the job in RUNNING.
12+
"""
13+
return uuid.UUID(str(request_id)).hex

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

Lines changed: 40 additions & 16 deletions
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
@@ -17,12 +18,29 @@
1718
Task = jclass("org.learningequality.Kolibri.task.Task")
1819

1920

20-
def _get_workmanager_job_ids():
21+
def _extract_work_ids(work_infos):
2122
"""
22-
Get all active job IDs from WorkManager (ENQUEUED and RUNNING states)
23+
Extract Kolibri job IDs and WorkManager request IDs from WorkInfo objects.
2324
24-
Returns:
25-
set: Set of job ID strings
25+
Returns (job_ids, request_ids) as sets of strings.
26+
"""
27+
job_ids = set()
28+
request_ids = set()
29+
for work_info in work_infos:
30+
request_ids.add(supervisor_id_from_request(work_info.getId()))
31+
tags = work_info.getTags()
32+
# Tags mix worker-class FQNs with our own; an explicit prefix identifies
33+
# ours rather than excluding known patterns.
34+
for tag in tags.toArray():
35+
if tag.startswith("kolibri:job:"):
36+
job_ids.add(tag[len("kolibri:job:") :])
37+
38+
return job_ids, request_ids
39+
40+
41+
def _get_active_work():
42+
"""
43+
Active WorkManager job IDs and request IDs (ENQUEUED + RUNNING).
2644
"""
2745
WorkManager = jclass("androidx.work.WorkManager")
2846
WorkInfo = jclass("androidx.work.WorkInfo")
@@ -38,17 +56,7 @@ def _get_workmanager_job_ids():
3856
work_query = WorkQuery.fromStates(states)
3957
work_info_list = work_manager.getWorkInfos(work_query).get()
4058

41-
# Extract Kolibri job IDs from tags
42-
# Tags include both worker class FQNs and our job ID tag set via Task.enqueueOnce
43-
# We use an explicit prefix to identify our tags rather than excluding known patterns
44-
job_ids = set()
45-
for work_info in work_info_list.toArray():
46-
tags = work_info.getTags()
47-
for tag in tags.toArray():
48-
if tag.startswith("kolibri:job:"):
49-
job_ids.add(tag[len("kolibri:job:") :])
50-
51-
return job_ids
59+
return _extract_work_ids(work_info_list.toArray())
5260

5361

5462
def _get_kolibri_active_jobs():
@@ -126,13 +134,29 @@ def _do_reconciliation():
126134
logger.info("Starting task reconciliation")
127135

128136
try:
137+
# First, clear ownership of any supervised jobs whose WorkManager
138+
# request is no longer live, so they can be re-dispatched. The live
139+
# set is the pre-reconcile snapshot of active WorkManager request ids.
140+
_, request_ids = _get_active_work()
141+
job_storage.reconcile_stalled_jobs(live_supervisor_ids=request_ids)
142+
logger.info(
143+
f"Reconciled stalled jobs against {len(request_ids)} live "
144+
"supervisor ids"
145+
)
146+
129147
kolibri_jobs = _get_kolibri_active_jobs()
130148
kolibri_job_ids = set(kolibri_jobs.keys())
131149
logger.info(
132150
f"Found {len(kolibri_job_ids)} active jobs in Kolibri database"
133151
)
134152

135-
workmanager_job_ids = _get_workmanager_job_ids()
153+
# Post-reconcile WorkManager snapshot: reconcile's requeue re-enqueues
154+
# jobs via the QUEUED hook, so they should be visible here to spare the
155+
# membership sync a redundant enqueue. Correctness does not hinge on the
156+
# timing, though: Task.enqueueOnce uses enqueueUniqueWork(job_id,
157+
# REPLACE), so a membership-sync re-enqueue is idempotent and collapses
158+
# any duplicate to a single worker regardless of snapshot timing.
159+
workmanager_job_ids, _ = _get_active_work()
136160
logger.info(
137161
f"Found {len(workmanager_job_ids)} active tasks in WorkManager"
138162
)

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

Lines changed: 3 additions & 0 deletions
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,6 +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),
36+
supervisor_id=supervisor_id_from_request(request_id),
3437
)
3538
logger.info(
3639
"Completed Kolibri task worker for job {} (request {})".format(

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

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
import sys
3+
import types
4+
from unittest.mock import MagicMock
5+
6+
# Ensure the python source dir is importable so `import taskworker`,
7+
# `import task_reconciler`, and `import android_app_plugin.kolibri_plugin`
8+
# resolve when tests run from the repo root.
9+
_SOURCE_DIR = os.path.dirname(os.path.dirname(__file__))
10+
if _SOURCE_DIR not in sys.path:
11+
sys.path.insert(0, _SOURCE_DIR)
12+
13+
14+
def _install_fake_module(name, **attrs):
15+
if name in sys.modules:
16+
return
17+
module = types.ModuleType(name)
18+
for attr, value in attrs.items():
19+
setattr(module, attr, value)
20+
sys.modules[name] = module
21+
22+
23+
# The Chaquopy Java bridge is the single hard boundary (the Android/Java
24+
# runtime). It does not exist off-device, so stub it. Everything else,
25+
# including first-party modules like auth, imports for real against these.
26+
_install_fake_module("java", jclass=MagicMock(return_value=MagicMock()))
27+
_install_fake_module("java.util", Locale=MagicMock())
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from unittest import mock
2+
3+
from android_app_plugin.kolibri_plugin import AndroidJobHook
4+
5+
from kolibri.core.tasks.job import Priority
6+
from kolibri.core.tasks.job import State
7+
8+
TASK_PATH = "android_app_plugin.kolibri_plugin.Task"
9+
10+
11+
def _make_orm_job():
12+
orm_job = mock.MagicMock()
13+
orm_job.id = "job-1"
14+
orm_job.scheduled_time = None
15+
orm_job.priority = Priority.REGULAR
16+
return orm_job
17+
18+
19+
def _make_job():
20+
job = mock.MagicMock()
21+
job.func = "kolibri.x"
22+
job.long_running = False
23+
# No status → update()'s notification branch is skipped, isolating enqueue.
24+
job.status.return_value = None
25+
return job
26+
27+
28+
def test_update_on_queued_transition_reenqueues():
29+
hook = AndroidJobHook()
30+
with mock.patch(TASK_PATH) as Task:
31+
hook.update(_make_job(), _make_orm_job(), state=State.QUEUED)
32+
assert Task.enqueueOnce.called
33+
34+
35+
def test_update_on_non_queued_state_does_not_reenqueue():
36+
hook = AndroidJobHook()
37+
with mock.patch(TASK_PATH) as Task:
38+
hook.update(_make_job(), _make_orm_job(), state=State.RUNNING)
39+
assert not Task.enqueueOnce.called
40+
41+
42+
def test_schedule_still_reenqueues():
43+
hook = AndroidJobHook()
44+
with mock.patch(TASK_PATH) as Task:
45+
hook.schedule(_make_job(), _make_orm_job())
46+
assert Task.enqueueOnce.called
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
End-to-end: a dashed request id must drive a job to COMPLETED against real
3+
storage, exercising the ownership fence the unit tests can't reach (see
4+
task_identity for why normalization is load-bearing).
5+
"""
6+
7+
from django.test import TestCase
8+
from task_identity import supervisor_id_from_request
9+
10+
from kolibri.core.tasks.job import Job
11+
from kolibri.core.tasks.job import State
12+
from kolibri.core.tasks.main import job_storage
13+
14+
# Captured mid-execution to prove the job is owned while RUNNING, not just
15+
# that ownership is cleared afterwards.
16+
_observed = {}
17+
18+
19+
def _observe_ownership_task(**kwargs):
20+
orm_job = job_storage.get_orm_job(_observed["job_id"])
21+
_observed["state"] = orm_job.state
22+
_observed["supervisor_id"] = orm_job.supervisor_id
23+
return "ok"
24+
25+
26+
class TaskWorkerLifecycleTest(TestCase):
27+
databases = "__all__"
28+
29+
def test_execute_via_request_id_reaches_completed(self):
30+
import taskworker
31+
32+
job = Job(_observe_ownership_task)
33+
job_id = job_storage.enqueue_job(job)
34+
_observed.clear()
35+
_observed["job_id"] = job_id
36+
37+
# A canonical (dashed) java.util.UUID.toString() request id.
38+
request_id = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
39+
taskworker.execute_job(job_id, request_id)
40+
41+
# Mid-execution: the job was RUNNING and owned by the normalized id.
42+
self.assertEqual(_observed["state"], State.RUNNING)
43+
self.assertEqual(
44+
_observed["supervisor_id"],
45+
supervisor_id_from_request(request_id),
46+
)
47+
48+
orm_job = job_storage.get_orm_job(job_id)
49+
self.assertEqual(orm_job.state, State.COMPLETED)
50+
# Ownership is cleared on a terminal transition.
51+
self.assertIsNone(orm_job.supervisor_id)

0 commit comments

Comments
 (0)