Skip to content

Commit 112226f

Browse files
authored
Merge pull request #60 from taskbadger/sk/parents
Support parent/child tasks
2 parents 1f1d0eb + 51a1166 commit 112226f

22 files changed

Lines changed: 878 additions & 110 deletions

integration_tests/tasks.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,33 @@ def add_auto_track(self, x, y):
2222
return x + y
2323

2424

25+
@shared_task(bind=True, base=taskbadger.celery.Task)
26+
def spawns_grandchild(self, x, y):
27+
"""Deferred by `spawns_child`, and defers a task of its own in turn.
28+
29+
Since this is already a child, the task it defers has to be flattened onto
30+
the root rather than nested under this one — the API only allows one level.
31+
"""
32+
grandchild = add.delay(x, y)
33+
return {"own_tb_id": self.taskbadger_task_id, "grandchild_tb_id": grandchild.taskbadger_task_id}
34+
35+
36+
@shared_task(bind=True, base=taskbadger.celery.Task)
37+
def spawns_child(self, x, y):
38+
"""Defers a task from inside its own run, so that task nests under this one."""
39+
return spawns_grandchild.delay(x, y).id
40+
41+
42+
@shared_task(bind=True, base=taskbadger.celery.Task)
43+
def chain_head(self):
44+
return self.taskbadger_task_id
45+
46+
47+
@shared_task(bind=True, base=taskbadger.celery.Task)
48+
def chain_tail(self, head_tb_id):
49+
return {"head_tb_id": head_tb_id, "own_tb_id": self.taskbadger_task_id}
50+
51+
2552
@shared_task(bind=True, base=taskbadger.celery.Task, taskbadger_heartbeat_interval=HEARTBEAT_INTERVAL)
2653
def slow_add(self, x, y):
2754
"""Runs long enough to go stale without a heartbeat, and never updates itself."""

integration_tests/test_celery.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import time
44

55
import pytest
6+
from celery import chain
67

78
import taskbadger
89
from taskbadger import StatusEnum
910

10-
from .tasks import HEARTBEAT_INTERVAL, add, add_auto_track, slow_add
11+
from .tasks import HEARTBEAT_INTERVAL, add, add_auto_track, chain_head, chain_tail, slow_add, spawns_child
1112

1213

1314
@pytest.fixture(autouse=True)
@@ -45,6 +46,42 @@ def test_celery_auto_track(celery_session_app, celery_session_worker):
4546
assert result.get(timeout=10, propagate=True) == a + b
4647

4748

49+
def test_celery_child_task_nests_under_its_parent(celery_session_app, celery_session_worker):
50+
a, b = random.randint(1, 1000), random.randint(1, 1000)
51+
root = spawns_child.delay(a, b)
52+
child_celery_id = root.get(timeout=15, propagate=True)
53+
54+
child = celery_session_app.AsyncResult(child_celery_id).get(timeout=15, propagate=True)
55+
56+
assert taskbadger.get_task(child["own_tb_id"]).parent == root.taskbadger_task_id
57+
58+
59+
def test_celery_grandchild_is_flattened_onto_the_root(celery_session_app, celery_session_worker):
60+
"""Nesting stops at one level, so a task deferred by a child joins it under
61+
the root instead of hanging off it (which the API would reject)."""
62+
a, b = random.randint(1, 1000), random.randint(1, 1000)
63+
root = spawns_child.delay(a, b)
64+
child_celery_id = root.get(timeout=15, propagate=True)
65+
66+
child = celery_session_app.AsyncResult(child_celery_id).get(timeout=15, propagate=True)
67+
grandchild = taskbadger.get_task(child["grandchild_tb_id"])
68+
69+
assert grandchild.parent == root.taskbadger_task_id
70+
assert grandchild.parent != child["own_tb_id"]
71+
72+
73+
def test_celery_chain_links_are_not_nested(celery_session_app, celery_session_worker):
74+
"""Celery dispatches the next chain link from inside the previous task's run,
75+
so it would otherwise be nested under it. Links are successors, not subtasks.
76+
"""
77+
ids = chain(chain_head.s(), chain_tail.s()).apply_async().get(timeout=20, propagate=True)
78+
79+
assert ids["head_tb_id"], "the first link should be tracked"
80+
assert ids["own_tb_id"], "the second link should be tracked"
81+
assert not taskbadger.get_task(ids["own_tb_id"]).parent
82+
assert taskbadger.list_tasks(parent=ids["head_tb_id"]).results == []
83+
84+
4885
def test_celery_heartbeat(celery_session_app, celery_session_worker):
4986
"""The worker pings the task while it runs, so it doesn't go stale."""
5087
a, b = random.randint(1, 1000), random.randint(1, 1000)

integration_tests/test_parents.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import pytest
2+
3+
import taskbadger as badger
4+
from taskbadger.exceptions import UnexpectedStatus
5+
6+
7+
def test_create_child_task():
8+
parent = badger.create_task("test parent")
9+
child = badger.create_task("test child", parent=parent.id)
10+
11+
assert child.parent == parent.id
12+
assert badger.get_task(child.id).parent == parent.id
13+
# the parent itself stays a root
14+
assert not badger.get_task(parent.id).parent
15+
16+
17+
def test_list_tasks_by_parent():
18+
parent = badger.create_task("test parent for listing")
19+
child = badger.create_task("test child for listing", parent=parent.id)
20+
badger.create_task("test unrelated task")
21+
22+
children = badger.list_tasks(parent=parent.id).results
23+
24+
assert [task.id for task in children] == [child.id]
25+
26+
27+
def test_set_parent_on_an_existing_task():
28+
parent = badger.create_task("test parent for update")
29+
child = badger.create_task("test child for update")
30+
assert not child.parent
31+
32+
child.update(parent=parent.id)
33+
34+
assert child.parent == parent.id
35+
assert badger.get_task(child.id).parent == parent.id
36+
37+
38+
def test_nesting_is_limited_to_one_level():
39+
"""The API rejects a grandchild, which is what the integrations' flattening
40+
exists to avoid."""
41+
parent = badger.create_task("test parent depth")
42+
child = badger.create_task("test child depth", parent=parent.id)
43+
44+
with pytest.raises(UnexpectedStatus):
45+
badger.create_task("test grandchild depth", parent=child.id)

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ dev = [
7070
"invoke",
7171
"pytest-celery",
7272
"redis",
73-
"openapi-python-client",
73+
# 0.29 generates `datetime.fromisoformat` calls, which can't parse the API's
74+
# `Z`-suffixed timestamps on Python 3.10.
75+
"openapi-python-client<0.29",
7476
"taskbadger[cli]",
7577
"taskbadger[sentry]",
7678
]

taskbadger.yaml

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ paths:
2828
description: Number of results to return per page.
2929
schema:
3030
type: integer
31+
- in: query
32+
name: parent
33+
schema:
34+
type: string
35+
description: Only return the tasks that are part of this task.
3136
- in: path
3237
name: project_slug
3338
schema:
@@ -388,7 +393,10 @@ paths:
388393
description: ''
389394
post:
390395
operationId: action_create
391-
description: Create an action for a task
396+
description: '**Deprecated.** Per-job actions are being retired. Configure alerts
397+
once at the project level with a global action instead of attaching them to
398+
individual tasks. This endpoint still works but will be removed in a future
399+
release.'
392400
summary: Create Action
393401
parameters:
394402
- in: path
@@ -420,6 +428,7 @@ paths:
420428
security:
421429
- projectKeyAuth: []
422430
- bearerAuth: []
431+
deprecated: true
423432
responses:
424433
'201':
425434
content:
@@ -471,7 +480,10 @@ paths:
471480
description: ''
472481
put:
473482
operationId: action_update
474-
description: Update an action
483+
description: '**Deprecated.** Per-job actions are being retired. Configure alerts
484+
once at the project level with a global action instead of attaching them to
485+
individual tasks. This endpoint still works but will be removed in a future
486+
release.'
475487
summary: Update Action
476488
parameters:
477489
- in: path
@@ -509,6 +521,7 @@ paths:
509521
security:
510522
- projectKeyAuth: []
511523
- bearerAuth: []
524+
deprecated: true
512525
responses:
513526
'200':
514527
content:
@@ -518,7 +531,10 @@ paths:
518531
description: ''
519532
patch:
520533
operationId: action_partial_update
521-
description: Update an action
534+
description: '**Deprecated.** Per-job actions are being retired. Configure alerts
535+
once at the project level with a global action instead of attaching them to
536+
individual tasks. This endpoint still works but will be removed in a future
537+
release.'
522538
summary: Update Action (partial)
523539
parameters:
524540
- in: path
@@ -555,6 +571,7 @@ paths:
555571
security:
556572
- projectKeyAuth: []
557573
- bearerAuth: []
574+
deprecated: true
558575
responses:
559576
'200':
560577
content:
@@ -564,7 +581,10 @@ paths:
564581
description: ''
565582
delete:
566583
operationId: action_cancel
567-
description: Cancel an action
584+
description: '**Deprecated.** Per-job actions are being retired. Configure alerts
585+
once at the project level with a global action instead of attaching them to
586+
individual tasks. This endpoint still works but will be removed in a future
587+
release.'
568588
summary: Cancel Action
569589
parameters:
570590
- in: path
@@ -596,6 +616,7 @@ paths:
596616
security:
597617
- projectKeyAuth: []
598618
- bearerAuth: []
619+
deprecated: true
599620
responses:
600621
'204':
601622
description: No response body
@@ -685,6 +706,12 @@ components:
685706
PatchedTaskRequest:
686707
type: object
687708
properties:
709+
parent:
710+
type: string
711+
minLength: 1
712+
nullable: true
713+
description: ID of the task this task is part of. Tasks can only be nested
714+
one level deep, and a task's parent can not be changed once set.
688715
name:
689716
type: string
690717
minLength: 1
@@ -791,6 +818,11 @@ components:
791818
project:
792819
type: string
793820
readOnly: true
821+
parent:
822+
type: string
823+
nullable: true
824+
description: ID of the task this task is part of. Tasks can only be nested
825+
one level deep, and a task's parent can not be changed once set.
794826
name:
795827
type: string
796828
description: Name of the task
@@ -894,6 +926,12 @@ components:
894926
TaskRequest:
895927
type: object
896928
properties:
929+
parent:
930+
type: string
931+
minLength: 1
932+
nullable: true
933+
description: ID of the task this task is part of. Tasks can only be nested
934+
one level deep, and a task's parent can not be changed once set.
897935
name:
898936
type: string
899937
minLength: 1

taskbadger/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from .internal.models import StatusEnum
44
from .mug import Badger, Session
55
from .safe_sdk import create_task_safe, update_task_safe
6-
from .sdk import DefaultMergeStrategy, Task, create_task, get_task, init, update_task
6+
from .sdk import DefaultMergeStrategy, Task, create_task, get_task, init, list_tasks, update_task
77

88
__all__ = [
99
"track",
@@ -20,6 +20,7 @@
2020
"create_task",
2121
"get_task",
2222
"init",
23+
"list_tasks",
2324
"update_task",
2425
]
2526

taskbadger/_current_task.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tracks which tracked task is running in the current context so that tasks
2+
created while it runs can be attached to it as children. Not part of the public
3+
API.
4+
5+
Task Badger nests tasks a single level deep, so `parent_id` never returns the
6+
id of a task that is itself a child: entering a task that already has a parent
7+
keeps that parent as the id offered for nesting. A task deferred by a child
8+
therefore lands alongside it under the same root rather than being rejected.
9+
10+
Call sites resolve a task's own parent themselves — they all have it to hand
11+
already (from the create response, or from the cache the status update fills),
12+
which keeps this module free of API calls.
13+
"""
14+
15+
from contextvars import ContextVar
16+
17+
# (id of the running task, id of its parent or None if it is a root task)
18+
_current: ContextVar[tuple[str, str | None] | None] = ContextVar("taskbadger_current_task", default=None)
19+
20+
21+
def enter_task(task_id: str, parent: str = None):
22+
"""Mark `task_id` as the task running in this context.
23+
24+
Arguments:
25+
task_id: The running task.
26+
parent: The running task's own parent, if it has one.
27+
28+
Returns:
29+
A token to pass to `exit_task`.
30+
"""
31+
return _current.set((task_id, parent))
32+
33+
34+
def exit_task(token) -> None:
35+
_current.reset(token)
36+
37+
38+
def current_task_id() -> str | None:
39+
"""The id of the tracked task running in this context, if any."""
40+
current = _current.get()
41+
return current[0] if current else None
42+
43+
44+
def parent_id() -> str | None:
45+
"""The id a task created right now should use as its `parent`.
46+
47+
`None` outside a tracked task.
48+
"""
49+
current = _current.get()
50+
if current is None:
51+
return None
52+
task_id, parent = current
53+
return parent or task_id

taskbadger/_integrations.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@ def safe_get_task(task_id: str):
7777
return task
7878

7979

80+
def parent_of(task) -> str | None:
81+
"""The id of ``task``'s parent, or ``None`` if it has none.
82+
83+
Accepts ``None`` (e.g. a failed fetch) and normalizes the generated model's
84+
``UNSET`` — returned for tasks fetched before the API grew the field — to
85+
``None``.
86+
"""
87+
parent = getattr(task, "parent", None) if task is not None else None
88+
return parent or None
89+
90+
8091
def match_task_name(task_name: str, includes, excludes) -> bool:
8192
"""Return True if ``task_name`` should be tracked under the given rules.
8293

0 commit comments

Comments
 (0)