Skip to content

Commit a4f0a05

Browse files
authored
Sticky activity queues (temporalio#45)
Fixed temporalio#36
1 parent 7b39449 commit a4f0a05

11 files changed

Lines changed: 470 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Some examples require extra dependencies. See each sample's directory for specif
4949
while running.
5050
* [hello_signal](hello/hello_signal.py) - Send signals to a workflow.
5151
<!-- Keep this list in alphabetical order -->
52+
* [activity_sticky_queue](activity_sticky_queue) - Uses unique task queues to ensure activities run on specific workers.
5253
* [activity_worker](activity_worker) - Use Python activities from a workflow in another language.
5354
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
5455
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.

activity_sticky_queues/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Sticky Activity Queues
2+
3+
This sample is a Python implementation of the [TypeScript "Sticky Workers" example](https://github.com/temporalio/samples-typescript/tree/main/activities-sticky-queues), full credit for the design to the authors of that sample. A [sticky execution](https://docs.temporal.io/tasks#sticky-execution) is a job distribution design pattern where all workflow computational tasks are executed on a single worker. In the Go and Java SDKs this is explicitly supported via the Session option, but in other SDKs a different approach is required.
4+
5+
Typical use cases for sticky executions include tasks where interaction with a filesystem is required, such as data processing or interacting with legacy access structures. This example will write text files to folders corresponding to each worker, located in the `demo_fs` folder. In production, these folders would typically be independent machines in a worker cluster.
6+
7+
This strategy is:
8+
- Create a `get_available_task_queue` activity that generates a unique task queue name, `unique_worker_task_queue`.
9+
- For activities intended to be "sticky", only register them in one Worker, and have that be the only Worker listening on that `unique_worker_task_queue`. This will be run on a series of `FileProcessing` workflows.
10+
- Execute workflows from the Client like normal. Check the Temporal Web UI to confirm tasks were staying with their respective worker.
11+
12+
It doesn't matter where the `get_available_task_queue` activity is run, so it can be "non sticky" as per Temporal default behavior. In this demo, `unique_worker_task_queue` is simply a `uuid` initialized in the Worker, but you can inject smart logic here to uniquely identify the Worker, [as Netflix did](https://community.temporal.io/t/using-dynamic-task-queues-for-traffic-routing/3045). Our example differs from the Node sample by running across 5 unique task queues.
13+
14+
Activities have been artificially slowed with `time.sleep(3)` to simulate slow activities.
15+
16+
### Running This Sample
17+
18+
To run, first see [README.md](../README.md) for prerequisites. Then, run the following from this directory to start the
19+
worker:
20+
21+
poetry run python worker.py
22+
23+
This will start the worker. Then, in another terminal, run the following to execute the workflow:
24+
25+
poetry run python starter.py
26+
27+
#### Example output:
28+
29+
```bash
30+
(temporalio-samples-py3.10) user@machine:~/samples-python/activities_sticky_queues$ poetry run python starter.py
31+
Output checksums:
32+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
33+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
34+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
35+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
36+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
37+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
38+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
39+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
40+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
41+
49d7419e6cba3575b3158f62d053f922aa08b23c64f05411cda3213b56c84ba4
42+
```
43+
44+
<details>
45+
<summary>Checking the history to see where activities are run</summary>
46+
All activities for the one workflow are running against the same task queue, which corresponds to unique workers:
47+
48+
![image](./static/all-activitites-on-same-task-queue.png)
49+
50+
</details>
51+
52+

activity_sticky_queues/__init__.py

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

activity_sticky_queues/starter.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import asyncio
2+
from uuid import uuid4
3+
4+
from temporalio.client import Client
5+
6+
from activity_sticky_queues.tasks import FileProcessing
7+
8+
9+
async def main():
10+
# Connect client
11+
client = await Client.connect("localhost:7233")
12+
13+
# Start 10 concurrent workflows
14+
futures = []
15+
for idx in range(10):
16+
result = client.execute_workflow(
17+
FileProcessing.run,
18+
id=f"activity_sticky_queue-workflow-id-{idx}",
19+
task_queue="activity_sticky_queue-distribution-queue",
20+
)
21+
await asyncio.sleep(0.1)
22+
futures.append(result)
23+
24+
checksums = await asyncio.gather(*futures)
25+
print("\n".join([f"Output checksums:"] + checksums))
26+
27+
28+
if __name__ == "__main__":
29+
asyncio.run(main())
304 KB
Loading

activity_sticky_queues/tasks.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import asyncio
2+
from dataclasses import dataclass
3+
from datetime import timedelta
4+
from hashlib import sha256
5+
from pathlib import Path
6+
7+
from temporalio import activity, workflow
8+
from temporalio.common import RetryPolicy
9+
10+
11+
def _get_delay_secs() -> float:
12+
return 3
13+
14+
15+
def _get_local_path() -> Path:
16+
return Path(__file__).parent / "demo_fs"
17+
18+
19+
def write_file(path: Path, body: str) -> None:
20+
"""Convenience write wrapper for mocking FS"""
21+
with open(path, "w") as handle:
22+
handle.write(body)
23+
24+
25+
def read_file(path) -> bytes:
26+
"""Convenience read wrapper for mocking FS"""
27+
with open(path, "rb") as handle:
28+
return handle.read()
29+
30+
31+
def delete_file(path) -> None:
32+
"""Convenience delete wrapper for mocking FS"""
33+
Path(path).unlink()
34+
35+
36+
def create_filepath(unique_worker_id: str, workflow_uuid: str) -> Path:
37+
"""Creates required folders and builds filepath"""
38+
directory = _get_local_path() / unique_worker_id
39+
directory.mkdir(parents=True, exist_ok=True)
40+
filepath = directory / workflow_uuid
41+
return filepath
42+
43+
44+
def process_file_contents(file_content: bytes) -> str:
45+
"""Returns hash of file string"""
46+
return sha256(file_content).hexdigest()
47+
48+
49+
@dataclass
50+
class DownloadObj:
51+
url: str
52+
unique_worker_id: str
53+
workflow_uuid: str
54+
55+
56+
@activity.defn
57+
async def get_available_task_queue() -> str:
58+
"""Just a stub for typedworkflow invocation."""
59+
raise NotImplementedError
60+
61+
62+
@activity.defn
63+
async def download_file_to_worker_filesystem(details: DownloadObj) -> str:
64+
"""Simulates downloading a file to a local filesystem"""
65+
# FS ops
66+
path = create_filepath(details.unique_worker_id, details.workflow_uuid)
67+
activity.logger.info(f"Downloading ${details.url} and saving to ${path}")
68+
69+
# Here is where the real download code goes. Developers should be careful
70+
# not to block an async activity. If there are concerns about blocking download
71+
# or disk IO, developers should use loop.run_in_executor or change this activity
72+
# to be synchronous. Also like for all non-immediate activities, be sure to
73+
# heartbeat during download.
74+
await asyncio.sleep(_get_delay_secs())
75+
body = "downloaded body"
76+
write_file(path, body)
77+
return str(path)
78+
79+
80+
@activity.defn
81+
async def work_on_file_in_worker_filesystem(path: str) -> str:
82+
"""Processing the file, in this case identical MD5 hashes"""
83+
content = read_file(path)
84+
checksum = process_file_contents(content)
85+
await asyncio.sleep(_get_delay_secs())
86+
activity.logger.info(f"Did some work on {path}, checksum {checksum}")
87+
return checksum
88+
89+
90+
@activity.defn
91+
async def clean_up_file_from_worker_filesystem(path: str) -> None:
92+
"""Deletes the file created in the first activity, but leaves the folder"""
93+
await asyncio.sleep(_get_delay_secs())
94+
activity.logger.info(f"Removing {path}")
95+
delete_file(path)
96+
97+
98+
@workflow.defn
99+
class FileProcessing:
100+
@workflow.run
101+
async def run(self) -> str:
102+
"""Workflow implementing the basic file processing example.
103+
104+
First, a worker is selected randomly. This is the "sticky worker" on which
105+
the workflow runs. This consists of a file download and some processing task,
106+
with a file cleanup if an error occurs.
107+
"""
108+
workflow.logger.info("Searching for available worker")
109+
unique_worker_task_queue = await workflow.execute_activity(
110+
activity=get_available_task_queue,
111+
start_to_close_timeout=timedelta(seconds=10),
112+
)
113+
workflow.logger.info(f"Matching workflow to worker {unique_worker_task_queue}")
114+
115+
download_params = DownloadObj(
116+
url="http://temporal.io",
117+
unique_worker_id=unique_worker_task_queue,
118+
workflow_uuid=str(workflow.uuid4()),
119+
)
120+
121+
download_path = await workflow.execute_activity(
122+
download_file_to_worker_filesystem,
123+
download_params,
124+
start_to_close_timeout=timedelta(seconds=10),
125+
task_queue=unique_worker_task_queue,
126+
)
127+
128+
checksum = "failed execution" # Sentinel value
129+
try:
130+
checksum = await workflow.execute_activity(
131+
work_on_file_in_worker_filesystem,
132+
download_path,
133+
start_to_close_timeout=timedelta(seconds=10),
134+
retry_policy=RetryPolicy(
135+
maximum_attempts=1,
136+
# maximum_interval=timedelta(milliseconds=500),
137+
),
138+
task_queue=unique_worker_task_queue,
139+
)
140+
finally:
141+
await workflow.execute_activity(
142+
clean_up_file_from_worker_filesystem,
143+
download_path,
144+
start_to_close_timeout=timedelta(seconds=10),
145+
task_queue=unique_worker_task_queue,
146+
)
147+
return checksum

activity_sticky_queues/worker.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import asyncio
2+
import logging # noqa
3+
import random
4+
from typing import List
5+
from uuid import UUID
6+
7+
from temporalio import activity
8+
from temporalio.client import Client
9+
from temporalio.worker import Worker
10+
11+
from activity_sticky_queues import tasks
12+
13+
interrupt_event = asyncio.Event()
14+
15+
16+
async def main():
17+
# Uncomment the line below to see logging
18+
# logging.basicConfig(level=logging.INFO)
19+
20+
# Comment line to see non-deterministic functionality
21+
random.seed(667)
22+
23+
# Create random task queues and build task queue selection function
24+
task_queues: List[str] = [
25+
f"activity_sticky_queue-host-{UUID(int=random.getrandbits(128))}"
26+
for _ in range(5)
27+
]
28+
29+
@activity.defn(name="get_available_task_queue")
30+
async def select_task_queue_random() -> str:
31+
"""Randomly assign the job to a queue"""
32+
return random.choice(task_queues)
33+
34+
# Start client
35+
client = await Client.connect("localhost:7233")
36+
37+
# Run a worker to distribute the workflows
38+
run_futures = []
39+
handle = Worker(
40+
client,
41+
task_queue="activity_sticky_queue-distribution-queue",
42+
workflows=[tasks.FileProcessing],
43+
activities=[select_task_queue_random],
44+
)
45+
run_futures.append(handle.run())
46+
print("Base worker started")
47+
48+
# Run the workers for the individual task queues
49+
for queue_id in task_queues:
50+
handle = Worker(
51+
client,
52+
task_queue=queue_id,
53+
activities=[
54+
tasks.download_file_to_worker_filesystem,
55+
tasks.work_on_file_in_worker_filesystem,
56+
tasks.clean_up_file_from_worker_filesystem,
57+
],
58+
)
59+
run_futures.append(handle.run())
60+
# Wait until interrupted
61+
print(f"Worker {queue_id} started")
62+
63+
print("All workers started, ctrl+c to exit")
64+
await asyncio.gather(*run_futures)
65+
66+
67+
if __name__ == "__main__":
68+
loop = asyncio.new_event_loop()
69+
try:
70+
loop.run_until_complete(main())
71+
except KeyboardInterrupt:
72+
interrupt_event.set()
73+
loop.run_until_complete(loop.shutdown_asyncgens())
74+
print("\nShutting down workers")

tests/activity_sticky_queues/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pathlib import Path
2+
from unittest import mock
3+
4+
from activity_sticky_queues import tasks
5+
6+
RETURNED_PATH = "valid/path"
7+
tasks._get_delay_secs = mock.MagicMock(return_value=0.0001)
8+
tasks._get_local_path = mock.MagicMock(return_value=Path(RETURNED_PATH))
9+
10+
11+
async def test_download_activity():
12+
worker_id = "an-id"
13+
workflow_uuid = "uuid"
14+
want = Path(RETURNED_PATH) / worker_id / workflow_uuid
15+
16+
details = tasks.DownloadObj("tdd.com", worker_id, workflow_uuid)
17+
with mock.patch.object(tasks, "write_file") as mock_write:
18+
response = await tasks.download_file_to_worker_filesystem(details)
19+
assert Path(response) == want
20+
mock_write.assert_called_once()
21+
22+
23+
async def test_processing_activity():
24+
file_contents = b"contents"
25+
want = tasks.process_file_contents(file_contents)
26+
27+
with mock.patch.object(tasks, "read_file", return_value=file_contents):
28+
response = await tasks.work_on_file_in_worker_filesystem(RETURNED_PATH)
29+
assert response == want
30+
31+
32+
async def test_clean_up_activity():
33+
with mock.patch.object(tasks, "delete_file") as mock_delete:
34+
await tasks.clean_up_file_from_worker_filesystem(RETURNED_PATH)
35+
mock_delete.assert_called_once_with(RETURNED_PATH)

0 commit comments

Comments
 (0)