From 407ae43c26aae012cba6319a75ee70d6b01eeb66 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Wed, 29 Jul 2026 14:13:05 -0400 Subject: [PATCH 1/9] bump python version to 1.31.0 --- pyproject.toml | 12 +- uv.lock | 904 ++----------------------------------------------- 2 files changed, 33 insertions(+), 883 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8f3ebd8..3bc559f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }] requires-python = ">=3.10" readme = "README.md" license = "MIT" -dependencies = ["temporalio>=1.30.0,<2", "protobuf>=5.29.6,<6"] +dependencies = ["temporalio>=1.31.0,<2", "protobuf>=5.29.6,<6"] [project.urls] Homepage = "https://github.com/temporalio/samples-python" @@ -38,17 +38,17 @@ external-storage = [ ] external-storage-redis = ["redis>=5.0.0,<8"] gevent = ["gevent>=25.4.2 ; python_version >= '3.8'"] -google-adk = ["temporalio[google-adk] >= 1.30.0", "google-adk>=1.27.0,<2"] +google-adk = ["temporalio[google-adk] >= 1.31.0", "google-adk>=2.2.0,<3"] langsmith-tracing = [ "openai>=1.4.0", "langsmith>=0.7.0", - "temporalio[pydantic,langsmith]>=1.30.0", + "temporalio[pydantic,langsmith]>=1.31.0", ] langgraph = [ "langgraph>=1.1.3", "langchain>=0.3.0", "langchain-anthropic>=0.3.0", - "temporalio[langgraph,langsmith]>=1.30.0", + "temporalio[langgraph,langsmith]>=1.31.0", ] nexus = ["nexus-rpc>=1.1.0,<2"] open-telemetry = [ @@ -57,7 +57,7 @@ open-telemetry = [ ] openai-agents = [ "openai-agents[litellm] >= 0.14.1", - "temporalio[openai-agents,opentelemetry] >= 1.30.0", + "temporalio[openai-agents,opentelemetry] >= 1.31.0", "requests>=2.32.0,<3", ] pydantic-converter = ["pydantic>=2.10.6,<3"] @@ -67,7 +67,7 @@ strands-agents = [ "strands-agents-tools>=0.5.2", "mcp>=1.0.0", "boto3>=1.34.92,<2", - "temporalio[strands-agents,pydantic]>=1.30.0", + "temporalio[strands-agents,pydantic]>=1.31.0", ] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] cloud-export-to-parquet = [ diff --git a/uv.lock b/uv.lock index 6063f75a..8d0a3f3c 100644 --- a/uv.lock +++ b/uv.lock @@ -218,21 +218,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] -[[package]] -name = "alembic" -version = "1.18.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.4" @@ -640,15 +625,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] -[[package]] -name = "cloudpickle" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -1055,47 +1031,26 @@ wheels = [ [[package]] name = "google-adk" -version = "1.35.2" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, - { name = "anyio" }, { name = "authlib" }, { name = "click" }, { name = "fastapi" }, - { name = "google-api-python-client" }, { name = "google-auth", extra = ["pyopenssl"] }, - { name = "google-cloud-aiplatform", extra = ["agent-engines"] }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-bigquery-storage" }, - { name = "google-cloud-bigtable" }, - { name = "google-cloud-dataplex" }, - { name = "google-cloud-discoveryengine" }, - { name = "google-cloud-pubsub" }, - { name = "google-cloud-secret-manager" }, - { name = "google-cloud-spanner" }, - { name = "google-cloud-speech" }, - { name = "google-cloud-storage" }, { name = "google-genai" }, { name = "graphviz" }, { name = "httpx" }, { name = "jsonschema" }, - { name = "mcp" }, { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-monitoring" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-resourcedetector-gcp" }, { name = "opentelemetry-sdk" }, - { name = "pyarrow" }, + { name = "packaging" }, { name = "pydantic" }, - { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "requests" }, - { name = "sqlalchemy" }, - { name = "sqlalchemy-spanner" }, { name = "starlette" }, { name = "tenacity" }, { name = "typing-extensions" }, @@ -1104,47 +1059,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/9b/6151ab3e5566b85008322605be7c3d27cc30b85946b7d026c8d56bdfc46c/google_adk-1.35.2.tar.gz", hash = "sha256:8ee69cc3ed2fb828664f761a50cc1351668d685506206eda6df2e2cb9f3f2147", size = 2431987, upload-time = "2026-06-17T20:43:04.974Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/8b/d014c98e987ed3a95ac3740d2b5c8e8e891bfd88c3ac2253fca9547f3b1f/google_adk-2.5.0.tar.gz", hash = "sha256:55b88cac9d5072d511fd3224e5f334e57fb2b0ae567507e531e03fdfb60c82c2", size = 3608134, upload-time = "2026-07-16T20:43:06.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/6f/d472034f28c78a0f423d56e421284ab40a726b2311788abdce9a1c41689c/google_adk-1.35.2-py3-none-any.whl", hash = "sha256:58db7398a9b6513d0a045e3d25ac9138f58165fb25404b3765581776de4c3ce4", size = 2876762, upload-time = "2026-06-17T20:43:02.678Z" }, -] - -[[package]] -name = "google-api-core" -version = "2.25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.197.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/09/081d66357118bd260f8f182cb1b2dd5bd32ca88e3714d7c93896cab946fc/google_api_python_client-2.197.0.tar.gz", hash = "sha256:32e03977eda4a66eafc6ae58dc9ec46426b6025636d5ef019c5703013eddd4e5", size = 14707398, upload-time = "2026-05-28T20:23:12.498Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e5/e9cc221fd75230974d4ef45eb72d2261feca3c110d5554215d516bfe6534/google_api_python_client-2.197.0-py3-none-any.whl", hash = "sha256:0f8b89aa75768161dd4f5092d6bcb386c13236b32e0d9a938c02f71342094d14", size = 15287302, upload-time = "2026-05-28T20:23:09.683Z" }, + { url = "https://files.pythonhosted.org/packages/52/fe/699d21edebd1305b6d23fd570140cf0cf921f34f66e4611d840684717c3a/google_adk-2.5.0-py3-none-any.whl", hash = "sha256:d247ca3639921a54a86feb797a88d08c1d2c9a60c3f5ff2805e49beb29a9cb8d", size = 4169976, upload-time = "2026-07-16T20:43:04.647Z" }, ] [[package]] @@ -1168,406 +1085,9 @@ requests = [ { name = "requests" }, ] -[[package]] -name = "google-auth-httplib2" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" }, -] - -[[package]] -name = "google-cloud-aiplatform" -version = "1.148.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-resource-manager" }, - { name = "google-cloud-storage" }, - { name = "google-genai" }, - { name = "packaging" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" }, -] - -[package.optional-dependencies] -agent-engines = [ - { name = "aiohttp" }, - { name = "cloudpickle" }, - { name = "google-cloud-iam" }, - { name = "google-cloud-logging" }, - { name = "google-cloud-trace" }, - { name = "opentelemetry-exporter-gcp-logging" }, - { name = "opentelemetry-exporter-gcp-trace" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] - -[[package]] -name = "google-cloud-appengine-logging" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/b9/fcafc8d2dc68975a65cdff74807547cff9b2a7b00e738d3f5ff0bd112867/google_cloud_appengine_logging-1.10.0.tar.gz", hash = "sha256:b5563e76010a36e6adf1cc489620c29ee4fb3b986b006d237e9a061eb0f0abb7", size = 17744, upload-time = "2026-06-03T14:52:40.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/b3/4eeb9f59c4e7e07e1f08704b6508249eea5760878810014e636026300416/google_cloud_appengine_logging-1.10.0-py3-none-any.whl", hash = "sha256:193675caaf062c41688a3e2c744b73614db82408bc7fb060353b6878d7134492", size = 18143, upload-time = "2026-06-03T14:51:55.174Z" }, -] - -[[package]] -name = "google-cloud-audit-log" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/46/b971191224557091cc865b47d527e61da180e33b9397904bdefdae1dcacd/google_cloud_audit_log-0.6.0.tar.gz", hash = "sha256:4dd343683c0bb31187ebef3426803f13159e950fbea3fe60a864855cfed959b8", size = 44674, upload-time = "2026-06-03T14:52:48.095Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/99/27c70286bfa3503e43f845578ed5c2ab30c0cc68e525c168286f05f9a51c/google_cloud_audit_log-0.6.0-py3-none-any.whl", hash = "sha256:8c5ecbc341ad3b3daf776981f6d7fd7ab5ff5a29c5dce3172c669b570e0f6717", size = 44853, upload-time = "2026-06-03T14:52:03.775Z" }, -] - -[[package]] -name = "google-cloud-bigquery" -version = "3.42.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth", extra = ["pyopenssl"] }, - { name = "google-cloud-core" }, - { name = "google-resumable-media" }, - { name = "packaging" }, - { name = "python-dateutil" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/a6/3d40767763061323d70ecf1870d9d5f428ee6a7e66f7b6e7297ee17f8b30/google_cloud_bigquery-3.42.0.tar.gz", hash = "sha256:4491a75f82d905101e75b690ca4c6791984bf4f50653706747537b05baa90213", size = 514647, upload-time = "2026-06-15T22:55:34.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/27/6ab5688744a08c15770ad10a4e430b16365f3ff95d9a59e565d47ce27175/google_cloud_bigquery-3.42.0-py3-none-any.whl", hash = "sha256:9df6a73043363cad17000c29591ed829be5f630ec30b85b29bc29062ab8b19a4", size = 263751, upload-time = "2026-06-15T22:55:32.352Z" }, -] - -[[package]] -name = "google-cloud-bigquery-storage" -version = "2.39.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1b/85/c998751fb4182b84872df7eafcdd2f68e325c791102b65d416975c020020/google_cloud_bigquery_storage-2.39.0.tar.gz", hash = "sha256:d5afd90ad06cf24d9167316cca70ab5b344e880fc13031d7392aa78ee76b8bb6", size = 309852, upload-time = "2026-06-03T15:13:01.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/f6/4157466c10181907d07786fb41df5d0a9ff339c1770b9e2a15cfe483e845/google_cloud_bigquery_storage-2.39.0-py3-none-any.whl", hash = "sha256:8c192b6263804f7bdd6f57a17e763ba7f03fa4e53d7ecafca0187e0fd6467d48", size = 305958, upload-time = "2026-06-03T15:12:15.889Z" }, -] - -[[package]] -name = "google-cloud-bigtable" -version = "2.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/2c/a62b2108459518914d75b8455dd69bac838d6bf276fe902320f5f16cf9cb/google_cloud_bigtable-2.38.0.tar.gz", hash = "sha256:0ad24f0106c2eb0f38e278b1641052e65882a4da0141d1f9ad78ea691724aaa3", size = 800955, upload-time = "2026-05-07T19:32:53.737Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/9d/9c0a81aa9cf6c058b02d3be194d70bcd7e4bd82f631c8110560c3908dbc4/google_cloud_bigtable-2.38.0-py3-none-any.whl", hash = "sha256:9f6a4bdbefb34d0420f41c574d9805d8a63d080d10be5a176205e3b322c122a1", size = 556168, upload-time = "2026-05-07T19:32:51.48Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, -] - -[[package]] -name = "google-cloud-dataplex" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/41/695b333dad5c3bda1df09c0744b574d14ed1cc5f8d933863723d95476ea5/google_cloud_dataplex-2.20.0.tar.gz", hash = "sha256:cbdc55ec184a58c6d444f6d37fcc9070664a345a8e110f34dd7233ed37f92047", size = 894255, upload-time = "2026-06-03T15:28:01.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/9f/ca0ca400de2a1a1dbf264a5c7b1c67deb17ddf0e941598a90da759c97751/google_cloud_dataplex-2.20.0-py3-none-any.whl", hash = "sha256:920bbc466eea3ce0168f9fefc4a16fd33e6ddb70537588666ce8e6609f1e1553", size = 691436, upload-time = "2026-06-03T15:27:10.355Z" }, -] - -[[package]] -name = "google-cloud-discoveryengine" -version = "0.13.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/cd/b33bbc4b096d937abee5ebfad3908b2bdc65acd1582191aa33beaa2b70a5/google_cloud_discoveryengine-0.13.12.tar.gz", hash = "sha256:d6b9f8fadd8ad0d2f4438231c5eb7772a317e9f59cafbcbadc19b5d54c609419", size = 3582382, upload-time = "2025-09-22T16:51:14.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/70/607f6011648f603d35e60a16c34aee68a0b39510e4268d4859f3268684f9/google_cloud_discoveryengine-0.13.12-py3-none-any.whl", hash = "sha256:295f8c6df3fb26b90fb82c2cd6fbcf4b477661addcb19a94eea16463a5c4e041", size = 3337248, upload-time = "2025-09-22T16:50:57.375Z" }, -] - -[[package]] -name = "google-cloud-iam" -version = "2.23.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/5f/128a1462354e0f8f0b7baff34b5a1a4e5cd7aee100d8db0eb39843b43d1d/google_cloud_iam-2.23.0.tar.gz", hash = "sha256:49246f6221026d381cff4f8d804daf1bb6416153f2504bf5ef54d4af2450b828", size = 561685, upload-time = "2026-05-07T08:04:16.253Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/ee/470f0c337a235b12c6a880df25809b8b11b33986510d66450cb5ef540a83/google_cloud_iam-2.23.0-py3-none-any.whl", hash = "sha256:a123ac45080a5c1735218a6b3db4c6e6ea12a1cdc86feec1c30ad1ede6c91fc6", size = 515952, upload-time = "2026-05-07T08:02:48.144Z" }, -] - -[[package]] -name = "google-cloud-logging" -version = "3.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-appengine-logging" }, - { name = "google-cloud-audit-log" }, - { name = "google-cloud-core" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/e749846f13c8d1c6c01eb6317e8b09abc130fe67b5d72081a48d1bf96971/google_cloud_logging-3.16.0.tar.gz", hash = "sha256:08a3076b8f0f724219d6f73b2a242ef69d51e8bce226133aebe41a25f23f5400", size = 293703, upload-time = "2026-06-03T15:28:23.862Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/d5/91035dd77e0033dfb00d52b2bcad1e4f7408eb931981f86a1584301670a8/google_cloud_logging-3.16.0-py3-none-any.whl", hash = "sha256:9e5bfbdfe7b5315ece00e1703a2ea25fe42ca35e0b4750127b019f50d069b01b", size = 234188, upload-time = "2026-06-03T15:27:37.407Z" }, -] - -[[package]] -name = "google-cloud-monitoring" -version = "2.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/84/9d/9522e169db3887e7f354bb9aa544a6e26c435ce19337e32432598db18c6f/google_cloud_monitoring-2.31.0.tar.gz", hash = "sha256:b4c9d3528c8643d4eb4b9d688cbb3c5914bc5f69b314ff7c5e1b47bdc073a9ae", size = 404747, upload-time = "2026-06-03T15:28:24.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/30/aa6635296da9c1c14d2e64f64e1cacd4f4debf8ab7e646c0559545f0f70d/google_cloud_monitoring-2.31.0-py3-none-any.whl", hash = "sha256:64f3d56ead48f0a0674f650cb2828c47b936582a02a27c55f2836681a86281c3", size = 391010, upload-time = "2026-06-03T15:27:39.536Z" }, -] - -[[package]] -name = "google-cloud-pubsub" -version = "2.39.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "grpcio-status" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/2b/4bf2c17e319ff65340389565b0e1b4d72696d87802b2f5f94390fbefa73c/google_cloud_pubsub-2.39.0.tar.gz", hash = "sha256:eed65e25f57f95bf3e02d96d7ee171688b23922471f9f21b5a91ed90e1282c0f", size = 402096, upload-time = "2026-06-03T15:28:26.396Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/20/dd0b27d4ad4577c062e77ff968ca3e2d404186cd78c8a2a53a0ef5fe5389/google_cloud_pubsub-2.39.0-py3-none-any.whl", hash = "sha256:7210d691a46d7a66559696899ebe6eb731e63de29b624964b3be4dd2d12d3e19", size = 324665, upload-time = "2026-06-03T15:27:41.119Z" }, -] - -[[package]] -name = "google-cloud-resource-manager" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" }, -] - -[[package]] -name = "google-cloud-secret-manager" -version = "2.29.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/7c/5c88cdde9664f6c75fb68aa11e0af4309a92bef38dd38df0456ffb0f469b/google_cloud_secret_manager-2.29.0.tar.gz", hash = "sha256:ee64133af8fdb3780affb65ec6ccf10ab15a0113d8edeba388665f4be87ce1be", size = 278437, upload-time = "2026-06-03T16:13:43.149Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/c2/fc3275bc42a522757cb5141d7dae51f048b93d2f5fe4574fcee5392cef03/google_cloud_secret_manager-2.29.0-py3-none-any.whl", hash = "sha256:21bac2d0adb0bb3c13c346d7223832f197c2266534528a1bf1402774e06395a3", size = 225042, upload-time = "2026-06-03T16:12:20.162Z" }, -] - -[[package]] -name = "google-cloud-spanner" -version = "3.68.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-cloud-monitoring" }, - { name = "grpc-google-iam-v1" }, - { name = "grpc-interceptor" }, - { name = "grpcio" }, - { name = "mmh3" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "sqlparse" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/2d/b857929745f57bb5b90f44970c02fdfbfb1184505ce4aa6e6c32550afb5f/google_cloud_spanner-3.68.0.tar.gz", hash = "sha256:90c55751cfc35bd58554c5715eab8be544095e21e40a805eb4d0c61a2bf07091", size = 904630, upload-time = "2026-06-12T18:03:27.665Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/f4/02ff12ebd23bb5af763b2b165deffe0dc78f933921903eb394a6ce4e0ed3/google_cloud_spanner-3.68.0-py3-none-any.whl", hash = "sha256:ad4aaf15e718fe0c54effbf510e1d9c7259f1252194c7192107848b06d8d2af8", size = 620018, upload-time = "2026-06-12T18:03:10.159Z" }, -] - -[[package]] -name = "google-cloud-speech" -version = "2.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/c1/5dc9795314f4aefea0b01b02e9f5486a198341ecc15fe47f89a61c68df63/google_cloud_speech-2.40.0.tar.gz", hash = "sha256:e89e688e4ce0b926754038bf992d0d0f065c5f1c3503bb20e6c46d08b63658fc", size = 404366, upload-time = "2026-06-03T16:13:59.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/78/afeca8d597fab54bdd823f857aad15d6f9c4628ff3cb72aa237d01700721/google_cloud_speech-2.40.0-py3-none-any.whl", hash = "sha256:7cc0302b3b9ca33d2eae9669da94a44316601a240942895362ac70e765b9f39c", size = 345427, upload-time = "2026-06-03T16:12:40.909Z" }, -] - -[[package]] -name = "google-cloud-storage" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/ef/7cefdca67a6c8b3af0ec38612f9e78e5a9f6179dd91352772ae1a9849246/google_cloud_storage-3.4.1.tar.gz", hash = "sha256:6f041a297e23a4b485fad8c305a7a6e6831855c208bcbe74d00332a909f82268", size = 17238203, upload-time = "2025-10-08T18:43:39.665Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/6e/b47d83d3a35231c6232566341b0355cce78fd4e6988a7343725408547b2c/google_cloud_storage-3.4.1-py3-none-any.whl", hash = "sha256:972764cc0392aa097be8f49a5354e22eb47c3f62370067fb1571ffff4a1c1189", size = 290142, upload-time = "2025-10-08T18:43:37.524Z" }, -] - -[[package]] -name = "google-cloud-trace" -version = "1.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/89/7b/c2a5848c4722373c92b500b65e6308ad89ca0c7c01054e0d948c58c107f2/google_cloud_trace-1.19.0.tar.gz", hash = "sha256:58293c6efcee6c74bb854ff01b008823bef66845c14f15ffa5209d545098a65d", size = 103875, upload-time = "2026-03-26T22:18:18.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/91/0090acafa7d2caf1bf0d7222d42935e118164a539f9f9a00a814afa63fa1/google_cloud_trace-1.19.0-py3-none-any.whl", hash = "sha256:59604c4c775c40af31b367df6bada0af34518cc35ac8cfedecd43898a120c51d", size = 108454, upload-time = "2026-03-26T22:14:32.631Z" }, -] - -[[package]] -name = "google-crc32c" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, - { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, - { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, - { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, - { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, - { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, - { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, - { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, - { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, - { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, -] - [[package]] name = "google-genai" -version = "1.75.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1581,21 +1101,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/59/3ed61240ef20b3ae6ed54e82c6f8b6d1f194947bc6679679dd6cdb037594/google_genai-1.75.0.tar.gz", hash = "sha256:56bac3991b311c93f980c0a2abcd287b672146905df1fbd71c92ed633d5a07cf", size = 539039, upload-time = "2026-05-04T22:48:54.857Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/9ea84cbeb8f09694564d3b0ee9dd59003551b308d47b61f251415df93982/google_genai-2.12.1.tar.gz", hash = "sha256:78c25217885d63dc430ca7c4526853512b164a25a93a8a0d0af5b85971aa1db0", size = 636710, upload-time = "2026-07-16T16:15:02.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/b6/552d40e96da22921eb1fead7c14b00b5b5473a20e45959488660fab35ee2/google_genai-1.75.0-py3-none-any.whl", hash = "sha256:8dc4c096e7d6288c3087f6893f582fe52468932464781edb8193bd92b9fefb2c", size = 793726, upload-time = "2026-05-04T22:48:53.033Z" }, -] - -[[package]] -name = "google-resumable-media" -version = "2.10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-crc32c" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/1369fb413fc2ba7f78acace5590b6e9990c52ab5d1d166aafaa1ae2c28c8/google_genai-2.12.1-py3-none-any.whl", hash = "sha256:686d5ec39bda345151d3ed1bac3915f01f49138b1ea519af2eb98f11cc55ebc4", size = 1023403, upload-time = "2026-07-16T16:14:59.79Z" }, ] [[package]] @@ -1610,11 +1118,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, -] - [[package]] name = "graphql-core" version = "3.2.8" @@ -1708,32 +1211,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, -] - -[[package]] -name = "grpc-interceptor" -version = "0.15.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "grpcio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/28/57449d5567adf4c1d3e216aaca545913fbc21a915f2da6790d6734aac76e/grpc-interceptor-0.15.4.tar.gz", hash = "sha256:1f45c0bcb58b6f332f37c637632247c9b02bc6af0fdceb7ba7ce8d2ebbfb0926", size = 19322, upload-time = "2023-11-16T02:05:42.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/ac/8d53f230a7443401ce81791ec50a3b0e54924bf615ad287654fa4a2f5cdc/grpc_interceptor-0.15.4-py3-none-any.whl", hash = "sha256:0035f33228693ed3767ee49d937bac424318db173fef4d2d0170b3215f254d9d", size = 20848, upload-time = "2023-11-16T02:05:40.913Z" }, -] - [[package]] name = "grpcio" version = "1.80.0" @@ -1795,20 +1272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, ] -[[package]] -name = "grpcio-status" -version = "1.71.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1863,18 +1326,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httplib2" -version = "0.31.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -2455,18 +1906,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/38/e6a4abb062e039d18d59538cc4e6fc370c2c10cd2bff4a2e546acb69dcb9/litellm-1.85.0-py3-none-any.whl", hash = "sha256:2bb449153610691faffd76f5b94a8c29e4b66fc5394156ebf54fd4fe92759b1a", size = 16978229, upload-time = "2026-05-17T01:59:11.902Z" }, ] -[[package]] -name = "mako" -version = "1.3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -2611,120 +2050,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mmh3" -version = "5.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" }, - { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" }, - { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" }, - { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" }, - { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" }, - { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" }, - { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, - { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, - { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, - { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, - { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, - { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, - { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, - { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, - { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, - { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, - { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, - { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, - { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, - { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, - { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, - { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, - { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, - { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, - { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, - { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, - { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, - { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, - { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, - { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, - { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, - { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, - { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, - { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, - { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, - { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, - { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, - { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, - { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, - { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, - { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, - { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, - { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, - { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, -] - [[package]] name = "moto" version = "5.2.1" @@ -3145,51 +2470,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] -[[package]] -name = "opentelemetry-exporter-gcp-logging" -version = "1.12.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-logging" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/e4/95ecebaa1c5134adaa0d0374028b25e3b3c5c08535d29a66d39d372a3d11/opentelemetry_exporter_gcp_logging-1.12.0a0.tar.gz", hash = "sha256:586529dbbcae5e22b880f7c121fde3f0fe8ae997aba1bad53f13c20eeb27cb3a", size = 22521, upload-time = "2026-04-28T20:59:40.237Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/93/3a0a9a62db0b90029a8160774e791044c0566aa94d5160ce7bbce8abf242/opentelemetry_exporter_gcp_logging-1.12.0a0-py3-none-any.whl", hash = "sha256:2aca9b01b3248c2fa95d38d01aa71aca8e22f640c44dba36ca6b883930762971", size = 14207, upload-time = "2026-04-28T20:59:35.109Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-monitoring" -version = "1.12.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-monitoring" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/f82b2858d00be6f91b917dc67ccf71688fa822448b2d26ace69b809f5835/opentelemetry_exporter_gcp_monitoring-1.12.0a0.tar.gz", hash = "sha256:2b285078cddd4af78a363a55b5478e89f7df6f15bba9139d3f484099e534df4c", size = 20839, upload-time = "2026-04-28T20:59:40.982Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/b5/1623886d049095bb5abcec0cd67a0e40c00ff1672a25f82ed9867f88c1e7/opentelemetry_exporter_gcp_monitoring-1.12.0a0-py3-none-any.whl", hash = "sha256:1a7daf8c9350d55010fa33d2c2f646655a03a81d0d8073a2ae0e066791d6177d", size = 13608, upload-time = "2026-04-28T20:59:36.315Z" }, -] - -[[package]] -name = "opentelemetry-exporter-gcp-trace" -version = "1.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-cloud-trace" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-resourcedetector-gcp" }, - { name = "opentelemetry-sdk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bf/55/32922e72d88421505383dfdba9c1ee6ad67253f94f2358f6e9dbc4ac3749/opentelemetry_exporter_gcp_trace-1.12.0.tar.gz", hash = "sha256:18c6e56fe123eed020d5005fdd819b196d64f651545bce1ca7e2e2cbaf9d343b", size = 18779, upload-time = "2026-04-28T20:59:41.974Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/68/c60e79992918eecb6de167e782c86946fdd5492bb163fe320f1a18959c3d/opentelemetry_exporter_gcp_trace-1.12.0-py3-none-any.whl", hash = "sha256:1538dab654bcb25e757ed34c94f27a2e30d90dc7deb3630f8d46d1111fcb3bad", size = 14013, upload-time = "2026-04-28T20:59:37.518Z" }, -] - [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.41.1" @@ -3220,24 +2500,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" }, ] -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.41.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, -] - [[package]] name = "opentelemetry-instrumentation" version = "0.62b1" @@ -3279,21 +2541,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, ] -[[package]] -name = "opentelemetry-resourcedetector-gcp" -version = "1.12.0a0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/ae/b62c5e986c9c7f908a15682ea173bcfcdc00403c0c85243ccbd30eca7fc2/opentelemetry_resourcedetector_gcp-1.12.0a0.tar.gz", hash = "sha256:d5e3f78283a272eb92547e00bbeff45b7332a34ae791a70ab4eba81af9bc3baf", size = 18797, upload-time = "2026-04-28T20:59:43.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/84/9db2999adbc41505af3e6717e8d958746778cbfc9e07ed9c670bf9d1e6db/opentelemetry_resourcedetector_gcp-1.12.0a0-py3-none-any.whl", hash = "sha256:e803688d14e2969fe816077be81f7b034368314d485863f12ce49daba7c81919", size = 18798, upload-time = "2026-04-28T20:59:39.257Z" }, -] - [[package]] name = "opentelemetry-sdk" version = "1.41.1" @@ -3828,18 +3075,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] -[[package]] -name = "proto-plus" -version = "1.28.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, -] - [[package]] name = "protobuf" version = "5.29.6" @@ -4783,84 +4018,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.51" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" }, - { url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" }, - { url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" }, - { url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" }, - { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, - { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, - { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, - { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, - { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, - { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, - { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, - { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, - { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, - { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, - { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, - { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, - { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, - { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, - { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, - { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, - { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, - { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, - { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, - { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, -] - -[[package]] -name = "sqlalchemy-spanner" -version = "1.19.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "alembic" }, - { name = "google-cloud-spanner" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/b6/ce05f1b8a9c486bbac26d7348625c78ba6e751decc25009f28880504c29d/sqlalchemy_spanner-1.19.0.tar.gz", hash = "sha256:834cec66fb418e5085a44c68cee570c594c66dd8535b67dd5e8be3571d172136", size = 82914, upload-time = "2026-06-03T16:14:49.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/38/8150a0022174d02956b0f6b586777006af2fc794b1baa72748a11fde039f/sqlalchemy_spanner-1.19.0-py3-none-any.whl", hash = "sha256:3367a89388d9b7106111fc48c7fac441163602c414ad157f62e18b5705cc760e", size = 31919, upload-time = "2026-06-03T16:13:39.522Z" }, -] - -[[package]] -name = "sqlparse" -version = "0.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, -] - [[package]] name = "sse-starlette" version = "3.4.4" @@ -4876,15 +4033,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.52.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -4952,7 +4109,7 @@ wheels = [ [[package]] name = "temporalio" -version = "1.30.0" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, @@ -4961,13 +4118,15 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/b0/ad8fc3cd7425c6551a637bf23c798e8fdd8eb7a3ec4fee4f46f7678ba8d2/temporalio-1.30.0.tar.gz", hash = "sha256:7c025919511bb465392d547e48ccb85fd560a995db4ebcc82fdb43cddf088e6f", size = 2686876, upload-time = "2026-07-02T21:04:46.713Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/43/676b56efaa64def06d4a9282cfc08d5a5b1c89de0ded47b692f8eaa30ca8/temporalio-1.31.0.tar.gz", hash = "sha256:2993f4b880170825414116dec7d7159a5d199efc279f29570be6b5c4a9f6edd5", size = 2785306, upload-time = "2026-07-29T17:55:10.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/39/842fdffe93388dd30ac12a53a698f71cbfb68b3bc938f30f3e5d6a36d4ad/temporalio-1.30.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6773a6b708dee7675fcbb681bf28e48337ce43b8467ceba8f903e78ae68909f8", size = 14520026, upload-time = "2026-07-02T21:04:31.384Z" }, - { url = "https://files.pythonhosted.org/packages/40/f3/a2237d5265eb29de591abeac7610a48616b590a1b923b4919f60ee81adfa/temporalio-1.30.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4038c3ce2d9acc12fef31dd16ef9be8cc7f721672da5662aa05e3942d0b5c9d1", size = 14018523, upload-time = "2026-07-02T21:04:34.405Z" }, - { url = "https://files.pythonhosted.org/packages/e6/57/dc648d812f4c688bd246a616f0d65c5f03b33675df2effb1b480e1df6d21/temporalio-1.30.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:108d1a56e174eabc18add58316084cdead230e239d3df22bbe999d6954986591", size = 14330502, upload-time = "2026-07-02T21:04:37.39Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e1/dbd57de0f5090891850c2ee5490319834a12b704076b11f32a4a149998d4/temporalio-1.30.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47b156138de30c2cd6723d5bfc06a30abcf680344c5481abb31f2a98a9b1f809", size = 14833364, upload-time = "2026-07-02T21:04:40.454Z" }, - { url = "https://files.pythonhosted.org/packages/30/2a/6d41289c11465ba276a8b417f31d2e469f0fe4b8afacd61d5244151c5fce/temporalio-1.30.0-cp310-abi3-win_amd64.whl", hash = "sha256:3adee28d5ec47bd6309a5eeef7b00126373f119bc5c1b058a34de098542f4da7", size = 15181893, upload-time = "2026-07-02T21:04:43.609Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7c/b91d831df50651a26562285de7c434a14243504eed853513da6e6afa3f19/temporalio-1.31.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5f9aaf06000768eb80cf5774457d227379dae455a3099aa0a18c4dca631c0d27", size = 14505266, upload-time = "2026-07-29T17:54:52.932Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d9/4655fc5d7ea662aac2af972e114ffeae45800bcded5ca3d31b2bcd99179d/temporalio-1.31.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:abbfb486ba00053ccfa57c9c38b38f80f5dbfd08c67beb7a621515a57a9b9e9b", size = 14037187, upload-time = "2026-07-29T17:54:55.771Z" }, + { url = "https://files.pythonhosted.org/packages/52/8e/52bd6a90cba3d4b257d7aa7426598e622cf05ab39a174b477535eba59c14/temporalio-1.31.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e840b462125274cb0d4748e7cedc386c303b2a51bf76bab30b96f1ebbc657ae", size = 14445065, upload-time = "2026-07-29T17:54:58.254Z" }, + { url = "https://files.pythonhosted.org/packages/d9/91/b644c2122943939e02c0e0ff1902b9c6ff7d5e0d6eec334abbfd4ee6bb17/temporalio-1.31.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04d26f0f634f5325f6a19a45cd67cc5a2da4bd35268d27997f6723134d38c8be", size = 14831115, upload-time = "2026-07-29T17:55:00.923Z" }, + { url = "https://files.pythonhosted.org/packages/8f/42/abc82a89323234026753ac801a1dbc36b4322dcdec5f1e38bee6405f3493/temporalio-1.31.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f23f36d0e5d2e67f2129fc1cb876cf1e4e614f791a717d692f7c15fb732abe41", size = 14542828, upload-time = "2026-07-29T17:55:03.378Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/9260f4544eb5d44867ef58e4a0e63dd3d643e26b2a94e21e027ceaeb0059/temporalio-1.31.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52f8cc7f0b5a19d49f0c2d748420267aaacc2be0bb614743de8d10faf77a57c9", size = 15019517, upload-time = "2026-07-29T17:55:05.758Z" }, + { url = "https://files.pythonhosted.org/packages/ce/96/b71e66a1921906f072038286e30946098531fd3363029f3d08c1723aa39a/temporalio-1.31.0-cp310-abi3-win_amd64.whl", hash = "sha256:aa6e9602829584b22037da6ccf99d2e089f02cec86ee7178206b1afb115cf092", size = 15527552, upload-time = "2026-07-29T17:55:08.287Z" }, ] [package.optional-dependencies] @@ -5095,7 +4254,7 @@ trio-async = [ [package.metadata] requires-dist = [ { name = "protobuf", specifier = ">=5.29.6,<6" }, - { name = "temporalio", specifier = ">=1.30.0,<2" }, + { name = "temporalio", specifier = ">=1.31.0,<2" }, ] [package.metadata.requires-dev] @@ -5136,19 +4295,19 @@ external-storage = [ external-storage-redis = [{ name = "redis", specifier = ">=5.0.0,<8" }] gevent = [{ name = "gevent", marker = "python_full_version >= '3.8'", specifier = ">=25.4.2" }] google-adk = [ - { name = "google-adk", specifier = ">=1.27.0,<2" }, - { name = "temporalio", extras = ["google-adk"], specifier = ">=1.30.0" }, + { name = "google-adk", specifier = ">=2.2.0,<3" }, + { name = "temporalio", extras = ["google-adk"], specifier = ">=1.31.0" }, ] langgraph = [ { name = "langchain", specifier = ">=0.3.0" }, { name = "langchain-anthropic", specifier = ">=0.3.0" }, { name = "langgraph", specifier = ">=1.1.3" }, - { name = "temporalio", extras = ["langgraph", "langsmith"], specifier = ">=1.30.0" }, + { name = "temporalio", extras = ["langgraph", "langsmith"], specifier = ">=1.31.0" }, ] langsmith-tracing = [ { name = "langsmith", specifier = ">=0.7.0" }, { name = "openai", specifier = ">=1.4.0" }, - { name = "temporalio", extras = ["pydantic", "langsmith"], specifier = ">=1.30.0" }, + { name = "temporalio", extras = ["pydantic", "langsmith"], specifier = ">=1.31.0" }, ] nexus = [{ name = "nexus-rpc", specifier = ">=1.1.0,<2" }] open-telemetry = [ @@ -5158,7 +4317,7 @@ open-telemetry = [ openai-agents = [ { name = "openai-agents", extras = ["litellm"], specifier = ">=0.14.1" }, { name = "requests", specifier = ">=2.32.0,<3" }, - { name = "temporalio", extras = ["openai-agents", "opentelemetry"], specifier = ">=1.30.0" }, + { name = "temporalio", extras = ["openai-agents", "opentelemetry"], specifier = ">=1.31.0" }, ] pydantic-converter = [{ name = "pydantic", specifier = ">=2.10.6,<3" }] sentry = [{ name = "sentry-sdk", specifier = ">=2.13.0" }] @@ -5167,7 +4326,7 @@ strands-agents = [ { name = "mcp", specifier = ">=1.0.0" }, { name = "strands-agents", specifier = ">=1.39.0" }, { name = "strands-agents-tools", specifier = ">=0.5.2" }, - { name = "temporalio", extras = ["strands-agents", "pydantic"], specifier = ">=1.30.0" }, + { name = "temporalio", extras = ["strands-agents", "pydantic"], specifier = ">=1.31.0" }, ] trio-async = [ { name = "trio", specifier = ">=0.28.0,<0.29" }, @@ -5458,15 +4617,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/28/fc144409c71569e928585f8f3c629d80d1ca3ef40175e9222f01588f98c9/tzlocal-5.4.3-py3-none-any.whl", hash = "sha256:24ce97bb58e2a973f7640ec2553ab4e6f6d5a0d0d1aa9dc43bca21d89e1feb82", size = 18039, upload-time = "2026-06-17T04:17:40.027Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" From c20ceeb13a487f691fa1dbf35d88aa58eb9e84d5 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Wed, 21 Oct 2026 22:31:12 -0400 Subject: [PATCH 2/9] Add workflow-session cache demo --- README.md | 1 + activity_sessions/.gitignore | 1 + activity_sessions/README.md | 174 ++++++++++++++++ activity_sessions/__init__.py | 1 + activity_sessions/activities.py | 69 +++++++ activity_sessions/models.py | 41 ++++ activity_sessions/starter.py | 215 ++++++++++++++++++++ activity_sessions/storage.py | 77 +++++++ activity_sessions/worker.py | 87 ++++++++ activity_sessions/workflows.py | 41 ++++ pyproject.toml | 1 + tests/activity_sessions/__init__.py | 1 + tests/activity_sessions/cache_test.py | 98 +++++++++ tests/activity_sessions/integration_test.py | 130 ++++++++++++ tests/activity_sessions/report_test.py | 45 ++++ 15 files changed, 982 insertions(+) create mode 100644 activity_sessions/.gitignore create mode 100644 activity_sessions/README.md create mode 100644 activity_sessions/__init__.py create mode 100644 activity_sessions/activities.py create mode 100644 activity_sessions/models.py create mode 100644 activity_sessions/starter.py create mode 100644 activity_sessions/storage.py create mode 100644 activity_sessions/worker.py create mode 100644 activity_sessions/workflows.py create mode 100644 tests/activity_sessions/__init__.py create mode 100644 tests/activity_sessions/cache_test.py create mode 100644 tests/activity_sessions/integration_test.py create mode 100644 tests/activity_sessions/report_test.py diff --git a/README.md b/README.md index d39428dc..f32a56fc 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [hello_signal](hello/hello_signal.py) - Send signals to a workflow. * [hello update](hello/hello_update.py) - Send a request to and a response from a client to a workflow execution. +* [activity_sessions](activity_sessions) - Experimental workflow-affine activities reuse process-local caches instead of rereading shared artifacts. * [activity_worker](activity_worker) - Use Python activities from a workflow in another language. * [batch_sliding_window](batch_sliding_window) - Batch processing with a sliding window of child workflows. * [bedrock](bedrock) - Orchestrate a chatbot with Amazon Bedrock. diff --git a/activity_sessions/.gitignore b/activity_sessions/.gitignore new file mode 100644 index 00000000..d0718296 --- /dev/null +++ b/activity_sessions/.gitignore @@ -0,0 +1 @@ +.data/ diff --git a/activity_sessions/README.md b/activity_sessions/README.md new file mode 100644 index 00000000..5b10425f --- /dev/null +++ b/activity_sessions/README.md @@ -0,0 +1,174 @@ +# Activity sessions: reuse worker-local conversation data + +**Experimental demo.** Requires the Python SDK session branch +([sdk-python#1979](https://github.com/temporalio/sdk-python/pull/1979)) and a server +built from [temporal#12630](https://github.com/temporalio/temporal/pull/12630). +The ordinary released SDK and stock downloaded dev server are not substitutes. + +The same `ProcessConversation` workflow calls the same regular activity five times +sequentially. The starter runs it with activity affinity off, then on, without +changing the worker configuration. + +```python +session_options = ( + workflow.ActivitySessionOptions.workflow_session() + if request.affinity_enabled + else None +) +``` + +Each step consumes the preceding step's output. Only a small content-addressed +reference passes through workflow history. Conversation data lives in a shared, +file-backed mock external store. Each worker process owns a private dictionary +cache, scoped by workflow Run ID and artifact reference. + +- A miss reads the artifact from the shared store. +- Every step persists its output before returning its reference, then caches that + output locally. +- The next step on the same process can reuse the output without another read. +- A different process or a restarted worker can read the persisted output instead. + +The cache is not workflow state and is not reconstructed by replay. It is an +optimization, not the only copy of the conversation. This is not an implementation +of SDK `ExternalStorage`; it makes the avoided artifact reads explicit. + +## Prerequisites + +Start the custom server with `system.enableStickyActivityExecution` enabled for +your namespace. Create the namespace if necessary. For example, after starting a +fresh in-memory server: + +```bash +temporal --address localhost:7233 operator namespace create --namespace default +``` + +Build the session SDK checkout following its `poe build-develop` instructions. +From the samples repository root, use that checkout's interpreter so the released +SDK dependency in this repository does not replace the experimental SDK: + +```bash +export SDK_PYTHON=/absolute/path/to/sdk-python/.venv/bin/python +``` + +All processes must use that same SDK build. The sample uses the repository's +standard `ClientConfig.load_client_connect_config()` configuration, defaulting to +`localhost:7233`. Authentication and namespace can be supplied via the existing +environment configuration mechanism. + +## Start two worker processes + +In terminal 1, from the repository root: + +```bash +"$SDK_PYTHON" -m activity_sessions.worker --identity worker-1 +``` + +In terminal 2: + +```bash +"$SDK_PYTHON" -m activity_sessions.worker --identity worker-2 +``` + +Both workers register the workflow and activity, enable +`WorkerSessionOptions()`, use fixed activity capacity, and disable eager dispatch. +Each prints its PID and each activity's cache result as it runs. Each invocation +creates its own client, Temporal runtime, activity instance, and cache. + +The shared directory defaults to `activity_sessions/.data` and is ignored by Git. +Use the same `--store-dir` and `--task-queue` on every worker and the starter. +The filesystem must be shared; this mock setup is intended for processes on one +machine, not separate hosts. + +## Run the comparison + +In terminal 3: + +```bash +"$SDK_PYTHON" -m activity_sessions.starter +``` + +Each execution has a different Run ID, so both start with a cold cache even when +the initial artifact has the same content. The starter prints: + +- Workflow-task worker identities obtained from history. +- Per-step executing identity, cache hit/miss, artifact reads, and artifact writes. +- A per-worker table including **Session support ON/OFF** and zero-execution rows. + +For example, with affinity enabled: + +```text +Step Activity worker Cache Artifact reads Artifact writes +1 worker-1 MISS 1 1 +2 worker-1 HIT 0 1 +3 worker-1 HIT 0 1 +4 worker-1 HIT 0 1 +5 worker-1 HIT 0 1 + +Worker Session support Steps Hits Misses Artifact reads Artifact writes +worker-1 ON 5 4 1 1 5 +worker-2 ON 0 0 0 0 0 +``` + +Every step still writes its output for durability. Affinity saves input reads; it +does not eliminate those writes. Counts exclude the starter's seed/verification +I/O and worker-roster I/O. + +**Ordinary routing may also colocate every activity.** The sample reports actual +placement and does not force alternation or assert a disabled run must have more +misses. Run again or use a larger pool for more observations: + +```bash +"$SDK_PYTHON" -m activity_sessions.starter --affinity off --steps 10 +"$SDK_PYTHON" -m activity_sessions.starter --affinity on --steps 10 +``` + +## Optional mixed pool + +Keep the session-capable workers above, then start additional activity-only +processes with unique identities: + +```bash +"$SDK_PYTHON" -m activity_sessions.worker --identity worker-3 --activity-only +``` + +Repeat with `worker-4` through `worker-10` if desired. These workers register the +same activity on the same normal queue but do not run workflows or poll sticky +activity queues. Their table rows show **Session support OFF**. + +All workflow-capable workers must enable session support: the current protocol +does not select a session-capable worker based on the workflow input. + +## Shutdown and limitations + +Use Ctrl+C or SIGTERM for graceful shutdown. A small local roster records each +worker's configured identity, role, PID, and queue so idle workers can appear in +the table. It is not a health check. Graceful shutdown removes the registration; +a hard crash can leave a stale entry. Use a fresh `--store-dir` for a fresh demo +after a crash rather than trusting stale registrations. + +This demonstrates the normal, healthy-worker path. It does not demonstrate +single-session lifetime isolation, stable destinations, worker-loss recovery, +sticky timeout fallback, or Continue-As-New placement. Cache entries are retained +for the process lifetime; production code needs eviction and resource limits. + +The result table observes worker colocation, not the physical queue of each poll. +No per-worker task queue names are managed by application code. + +## Tests + +```bash +"$SDK_PYTHON" -m pytest tests/activity_sessions -q +``` + +The cache/storage/report tests do not require a server. The opt-in SDK/server test +launches independent worker processes in three-worker and ten-worker configurations +(two session workers plus activity-only workers), runs the starter CLI, checks +both modes, and shuts down its own worker processes: + +```bash +TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE=1 "$SDK_PYTHON" -m pytest \ + tests/activity_sessions -q -s --workflow-environment localhost:7233 +``` + +This test requires the custom server, namespace gate, and session SDK. It is +skipped by default; it does not start or reconfigure the custom server. diff --git a/activity_sessions/__init__.py b/activity_sessions/__init__.py new file mode 100644 index 00000000..4185fae1 --- /dev/null +++ b/activity_sessions/__init__.py @@ -0,0 +1 @@ +TASK_QUEUE = "activity-session-cache" diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py new file mode 100644 index 00000000..f215cd3d --- /dev/null +++ b/activity_sessions/activities.py @@ -0,0 +1,69 @@ +import asyncio + +from temporalio import activity +from temporalio.exceptions import ApplicationError + +from activity_sessions.models import ( + ACTIVITY_NAME, + ConversationContext, + StepInput, + StepResult, +) +from activity_sessions.storage import FileArtifactStore + + +class ConversationActivities: + def __init__( + self, + store: FileArtifactStore, + worker_identity: str, + session_enabled: bool, + ) -> None: + self.store = store + self.worker_identity = worker_identity + self.session_enabled = session_enabled + self.cache: dict[tuple[str, str], ConversationContext] = {} + + @activity.defn(name=ACTIVITY_NAME) + async def process_conversation_step(self, request: StepInput) -> StepResult: + run_id = activity.info().workflow_run_id + if run_id is None: + raise ApplicationError( + "This sample requires a workflow activity", non_retryable=True + ) + + cache_key = (run_id, request.input_ref) + context = self.cache.get(cache_key) + cache_hit = context is not None + artifact_reads = 0 + if context is None: + context = await asyncio.to_thread(self.store.read, request.input_ref) + artifact_reads += 1 + self.cache[cache_key] = context + + output = ConversationContext( + messages=[ + *context.messages, + f"Processed conversation step {request.step}.", + ], + completed_steps=[*context.completed_steps, request.step], + ) + # Persist before advertising the reference: another worker must be able to recover on a miss. + output_ref = await asyncio.to_thread(self.store.write, output) + artifact_writes = 1 + self.cache[(run_id, output_ref)] = output + print( + f"[{self.worker_identity}] run={run_id} step={request.step} " + f"cache={'HIT' if cache_hit else 'MISS'} " + f"artifact_reads={artifact_reads} artifact_writes={artifact_writes}", + flush=True, + ) + return StepResult( + step=request.step, + worker_identity=self.worker_identity, + session_enabled=self.session_enabled, + cache_hit=cache_hit, + artifact_reads=artifact_reads, + artifact_writes=artifact_writes, + output_ref=output_ref, + ) diff --git a/activity_sessions/models.py b/activity_sessions/models.py new file mode 100644 index 00000000..0a3c5ba4 --- /dev/null +++ b/activity_sessions/models.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field + +ACTIVITY_NAME = "process_conversation_step" + + +@dataclass(frozen=True) +class ConversationRequest: + input_ref: str + affinity_enabled: bool + steps: int = 5 + + +@dataclass(frozen=True) +class StepInput: + step: int + input_ref: str + + +@dataclass(frozen=True) +class StepResult: + step: int + worker_identity: str + session_enabled: bool + cache_hit: bool + artifact_reads: int + artifact_writes: int + output_ref: str + + +@dataclass +class ConversationContext: + messages: list[str] + completed_steps: list[int] = field(default_factory=list) + + +@dataclass(frozen=True) +class WorkerRegistration: + identity: str + session_enabled: bool + pid: int + task_queue: str diff --git a/activity_sessions/starter.py b/activity_sessions/starter.py new file mode 100644 index 00000000..4c0672c0 --- /dev/null +++ b/activity_sessions/starter.py @@ -0,0 +1,215 @@ +import argparse +import asyncio +import uuid +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import timedelta +from pathlib import Path + +from temporalio.client import Client, WorkflowHistory +from temporalio.envconfig import ClientConfig + +from activity_sessions import TASK_QUEUE +from activity_sessions.models import ( + ConversationContext, + ConversationRequest, + StepResult, + WorkerRegistration, +) +from activity_sessions.storage import FileArtifactStore +from activity_sessions.workflows import ProcessConversation + + +@dataclass +class WorkerSummary: + identity: str + session_enabled: bool + steps: int = 0 + hits: int = 0 + misses: int = 0 + artifact_reads: int = 0 + artifact_writes: int = 0 + + +def summarize_workers( + results: Sequence[StepResult], roster: Sequence[WorkerRegistration] +) -> list[WorkerSummary]: + summaries = { + item.identity: WorkerSummary(item.identity, item.session_enabled) + for item in roster + } + for result in results: + summary = summaries.setdefault( + result.worker_identity, + WorkerSummary(result.worker_identity, result.session_enabled), + ) + if summary.session_enabled != result.session_enabled: + raise ValueError( + f"Worker {summary.identity!r} changed session capability during the run" + ) + summary.steps += 1 + summary.hits += int(result.cache_hit) + summary.misses += int(not result.cache_hit) + summary.artifact_reads += result.artifact_reads + summary.artifact_writes += result.artifact_writes + return sorted(summaries.values(), key=lambda item: item.identity) + + +def format_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: + all_rows = [headers, *rows] + widths = [max(len(row[index]) for row in all_rows) for index in range(len(headers))] + return "\n".join( + " ".join(value.ljust(width) for value, width in zip(row, widths)) + for row in all_rows + ) + + +def print_comparison( + affinity_enabled: bool, + run_id: str, + results: Sequence[StepResult], + history: WorkflowHistory, + roster: Sequence[WorkerRegistration], +) -> None: + owners = { + event.workflow_task_started_event_attributes.identity + for event in history.events + if event.HasField("workflow_task_started_event_attributes") + } + print(f"\nActivity affinity: {'ON' if affinity_enabled else 'OFF'}") + print(f"Workflow Run ID: {run_id}") + print(f"Workflow-task worker(s): {', '.join(sorted(owners))}") + print( + format_table( + ["Step", "Activity worker", "Cache", "Artifact reads", "Artifact writes"], + [ + [ + str(result.step), + result.worker_identity, + "HIT" if result.cache_hit else "MISS", + str(result.artifact_reads), + str(result.artifact_writes), + ] + for result in results + ], + ) + ) + print("\nPer-worker totals for this run (idle rows use the local worker roster):") + print( + format_table( + [ + "Worker", + "Session support", + "Steps", + "Hits", + "Misses", + "Artifact reads", + "Artifact writes", + ], + [ + [ + item.identity, + "ON" if item.session_enabled else "OFF", + str(item.steps), + str(item.hits), + str(item.misses), + str(item.artifact_reads), + str(item.artifact_writes), + ] + for item in summarize_workers(results, roster) + ], + ) + ) + print( + f"Workers executing activities: {len({result.worker_identity for result in results})}; " + f"artifact reads: {sum(result.artifact_reads for result in results)}; " + f"artifact writes: {sum(result.artifact_writes for result in results)}" + ) + if affinity_enabled: + if len(owners) != 1 or any( + result.worker_identity not in owners for result in results + ): + raise RuntimeError( + "Session activities did not remain on one workflow owner" + ) + if results[0].cache_hit or any(not result.cache_hit for result in results[1:]): + raise RuntimeError( + "Expected a cold first step followed by local cache hits" + ) + print( + "Verified: activities stayed with the workflow owner; subsequent steps reused its cache." + ) + elif len({result.worker_identity for result in results}) == 1: + print( + "Ordinary routing colocated these activities by chance; placement is not guaranteed." + ) + + +async def run_comparison( + mode: str, steps: int, task_queue: str, store_directory: Path +) -> None: + if steps < 1: + raise ValueError("At least one step is required") + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + store = FileArtifactStore(store_directory) + roster = store.list_workers(task_queue) + if not any(item.session_enabled for item in roster): + raise RuntimeError( + "Start at least one session-capable worker using the same store and task queue" + ) + input_ref = store.write( + ConversationContext( + messages=["Hydrated conversation context from the mock external store."] + ) + ) + modes = [False, True] if mode == "compare" else [mode == "on"] + for affinity_enabled in modes: + handle = await client.start_workflow( + ProcessConversation.run, + ConversationRequest(input_ref, affinity_enabled, steps), + id=f"conversation-{'affine' if affinity_enabled else 'ordinary'}-{uuid.uuid4()}", + task_queue=task_queue, + execution_timeout=timedelta(minutes=2), + ) + results = await handle.result() + if len(results) != steps: + raise RuntimeError(f"Expected {steps} step results, got {len(results)}") + final_context = store.read(results[-1].output_ref) + if final_context.completed_steps != list(range(1, steps + 1)): + raise RuntimeError( + "Persisted output does not contain the complete conversation pipeline" + ) + print_comparison( + affinity_enabled, + handle.first_execution_run_id or "", + results, + await handle.fetch_history(), + store.list_workers(task_queue), + ) + print( + "\nRead/write totals count activity artifact I/O, excluding starter and worker-roster I/O." + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compare ordinary and workflow-session activity caches." + ) + parser.add_argument( + "--affinity", choices=["compare", "on", "off"], default="compare" + ) + parser.add_argument("--steps", type=int, default=5) + parser.add_argument("--task-queue", default=TASK_QUEUE) + parser.add_argument( + "--store-dir", type=Path, default=Path("activity_sessions/.data") + ) + args = parser.parse_args() + asyncio.run( + run_comparison(args.affinity, args.steps, args.task_queue, args.store_dir) + ) + + +if __name__ == "__main__": + main() diff --git a/activity_sessions/storage.py b/activity_sessions/storage.py new file mode 100644 index 00000000..e328d687 --- /dev/null +++ b/activity_sessions/storage.py @@ -0,0 +1,77 @@ +import hashlib +import json +import os +import re +import tempfile +from contextlib import suppress +from dataclasses import asdict +from pathlib import Path + +from activity_sessions.models import ConversationContext, WorkerRegistration + + +class FileArtifactStore: + """A shared, file-backed stand-in for an external object store, not a cache.""" + + def __init__(self, directory: Path) -> None: + self.directory = directory + self.artifacts = directory / "artifacts" + self.workers = directory / "workers" + self.artifacts.mkdir(parents=True, exist_ok=True) + self.workers.mkdir(parents=True, exist_ok=True) + + def write(self, context: ConversationContext) -> str: + data = json.dumps(asdict(context), sort_keys=True).encode() + reference = hashlib.sha256(data).hexdigest() + self._atomic_write(self.artifacts / f"{reference}.json", data) + return reference + + def read(self, reference: str) -> ConversationContext: + if not re.fullmatch(r"[0-9a-f]{64}", reference): + raise ValueError("An artifact reference must be a SHA-256 digest") + data = (self.artifacts / f"{reference}.json").read_bytes() + if hashlib.sha256(data).hexdigest() != reference: + raise ValueError(f"Artifact checksum mismatch: {reference}") + return ConversationContext(**json.loads(data)) + + def register_worker(self, worker: WorkerRegistration) -> None: + path = ( + self.workers + / f"{hashlib.sha256(worker.identity.encode()).hexdigest()}.json" + ) + if path.exists(): + raise ValueError( + f"{worker.identity!r} is already registered. Use a unique identity " + "or a fresh store directory if a previous process crashed." + ) + self._atomic_write(path, json.dumps(asdict(worker)).encode()) + + def unregister_worker(self, worker: WorkerRegistration) -> None: + path = ( + self.workers + / f"{hashlib.sha256(worker.identity.encode()).hexdigest()}.json" + ) + with suppress(FileNotFoundError): + if WorkerRegistration(**json.loads(path.read_bytes())) == worker: + path.unlink() + + def list_workers(self, task_queue: str) -> list[WorkerRegistration]: + workers = [] + for path in self.workers.glob("*.json"): + with suppress(FileNotFoundError): + worker = WorkerRegistration(**json.loads(path.read_bytes())) + if worker.task_queue == task_queue: + workers.append(worker) + return sorted(workers, key=lambda worker: worker.identity) + + @staticmethod + def _atomic_write(path: Path, data: bytes) -> None: + # Readers in other processes must never observe a partially written artifact. + with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output: + temporary = Path(output.name) + try: + output.write(data) + output.close() + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py new file mode 100644 index 00000000..7bd515db --- /dev/null +++ b/activity_sessions/worker.py @@ -0,0 +1,87 @@ +import argparse +import asyncio +import os +import signal +from contextlib import suppress +from datetime import timedelta +from pathlib import Path + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import PollerBehaviorSimpleMaximum, Worker, WorkerSessionOptions + +from activity_sessions import TASK_QUEUE +from activity_sessions.activities import ConversationActivities +from activity_sessions.models import WorkerRegistration +from activity_sessions.storage import FileArtifactStore +from activity_sessions.workflows import ProcessConversation + + +async def run_worker( + identity: str, activity_only: bool, task_queue: str, store_directory: Path +) -> None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config, identity=identity) + session_enabled = not activity_only + store = FileArtifactStore(store_directory) + activities = ConversationActivities(store, identity, session_enabled) + registration = WorkerRegistration( + identity, session_enabled, os.getpid(), task_queue + ) + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for signum in (signal.SIGINT, signal.SIGTERM): + with suppress(NotImplementedError): + loop.add_signal_handler(signum, stop.set) + + store.register_worker(registration) + try: + async with Worker( + client, + task_queue=task_queue, + workflows=[ProcessConversation] if session_enabled else [], + activities=[activities.process_conversation_step], + session_worker_options=WorkerSessionOptions() if session_enabled else None, + disable_eager_activity_execution=True, + max_cached_workflows=100 if session_enabled else 0, + max_concurrent_activities=2, + workflow_task_poller_behavior=PollerBehaviorSimpleMaximum(2), + activity_task_poller_behavior=PollerBehaviorSimpleMaximum(2), + sticky_queue_schedule_to_start_timeout=timedelta(minutes=1), + ): + print( + f"Worker {identity} pid={os.getpid()} queue={task_queue} " + f"session_polling={'ON' if session_enabled else 'OFF'} " + f"role={'workflow+activity' if session_enabled else 'activity-only'} " + f"store={store_directory.resolve()}", + flush=True, + ) + await stop.wait() + finally: + store.unregister_worker(registration) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run one process with its own activity cache." + ) + parser.add_argument("--identity", required=True) + parser.add_argument("--activity-only", action="store_true") + parser.add_argument("--task-queue", default=TASK_QUEUE) + parser.add_argument( + "--store-dir", type=Path, default=Path("activity_sessions/.data") + ) + args = parser.parse_args() + try: + asyncio.run( + run_worker( + args.identity, args.activity_only, args.task_queue, args.store_dir + ) + ) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/activity_sessions/workflows.py b/activity_sessions/workflows.py new file mode 100644 index 00000000..5a0e4b7a --- /dev/null +++ b/activity_sessions/workflows.py @@ -0,0 +1,41 @@ +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ApplicationError + +from activity_sessions.models import ( + ACTIVITY_NAME, + ConversationRequest, + StepInput, + StepResult, +) + + +@workflow.defn +class ProcessConversation: + @workflow.run + async def run(self, request: ConversationRequest) -> list[StepResult]: + if request.steps < 1: + raise ApplicationError( + "At least one conversation step is required", non_retryable=True + ) + session_options = ( + workflow.ActivitySessionOptions.workflow_session() + if request.affinity_enabled + else None + ) + input_ref = request.input_ref + results = [] + for step in range(1, request.steps + 1): + result = await workflow.execute_activity( + ACTIVITY_NAME, + StepInput(step=step, input_ref=input_ref), + result_type=StepResult, + session_options=session_options, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy(maximum_attempts=1), + ) + results.append(result) + input_ref = result.output_ref + return results diff --git a/pyproject.toml b/pyproject.toml index 3bc559f9..d08665ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ exclude = ["lambda_worker/**"] include = ["./**/*.py"] exclude = ["lambda_worker/**"] packages = [ + "activity_sessions", "activity_worker", "bedrock", "cloud_export_to_parquet", diff --git a/tests/activity_sessions/__init__.py b/tests/activity_sessions/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/activity_sessions/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/activity_sessions/cache_test.py b/tests/activity_sessions/cache_test.py new file mode 100644 index 00000000..b1f69539 --- /dev/null +++ b/tests/activity_sessions/cache_test.py @@ -0,0 +1,98 @@ +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +import pytest +from temporalio.testing import ActivityEnvironment + +from activity_sessions.activities import ConversationActivities +from activity_sessions.models import ConversationContext, StepInput, WorkerRegistration +from activity_sessions.storage import FileArtifactStore + + +async def test_next_step_reuses_locally_cached_output(tmp_path: Path) -> None: + store = FileArtifactStore(tmp_path) + initial_ref = store.write(ConversationContext(["Conversation context"])) + activities = ConversationActivities(store, "worker-1", True) + env = ActivityEnvironment() + env.info = replace(env.info, workflow_run_id="run-1") + with ( + patch.object(store, "read", wraps=store.read) as reads, + patch.object(store, "write", wraps=store.write) as writes, + ): + first = await env.run( + activities.process_conversation_step, StepInput(1, initial_ref) + ) + second = await env.run( + activities.process_conversation_step, StepInput(2, first.output_ref) + ) + assert not first.cache_hit + assert second.cache_hit + assert reads.call_count == first.artifact_reads + second.artifact_reads == 1 + assert writes.call_count == first.artifact_writes + second.artifact_writes == 2 + assert store.read(second.output_ref).completed_steps == [1, 2] + assert store.read(initial_ref).completed_steps == [] + + +async def test_new_worker_recovers_output_from_external_store(tmp_path: Path) -> None: + store = FileArtifactStore(tmp_path) + initial_ref = store.write(ConversationContext(["Conversation context"])) + producer = ConversationActivities(store, "worker-1", True) + env = ActivityEnvironment() + env.info = replace(env.info, workflow_run_id="run-1") + first = await env.run(producer.process_conversation_step, StepInput(1, initial_ref)) + + consumer_store = FileArtifactStore(tmp_path) + consumer = ConversationActivities(consumer_store, "worker-2", False) + with patch.object(consumer_store, "read", wraps=consumer_store.read) as reads: + second = await env.run( + consumer.process_conversation_step, StepInput(2, first.output_ref) + ) + assert not second.cache_hit + assert reads.call_count == second.artifact_reads == 1 + assert not second.session_enabled + assert consumer_store.read(second.output_ref).completed_steps == [1, 2] + + +async def test_new_workflow_run_starts_cold_on_same_worker(tmp_path: Path) -> None: + store = FileArtifactStore(tmp_path) + initial_ref = store.write(ConversationContext(["Conversation context"])) + activities = ConversationActivities(store, "worker-1", True) + env = ActivityEnvironment() + env.info = replace(env.info, workflow_run_id="run-1") + await env.run(activities.process_conversation_step, StepInput(1, initial_ref)) + env.info = replace(env.info, workflow_run_id="run-2") + result = await env.run( + activities.process_conversation_step, StepInput(1, initial_ref) + ) + assert not result.cache_hit + assert result.artifact_reads == 1 + + +def test_artifact_integrity_and_safe_references(tmp_path: Path) -> None: + store = FileArtifactStore(tmp_path) + reference = store.write(ConversationContext(["Conversation context"])) + with pytest.raises(ValueError, match="SHA-256"): + store.read("../outside-store") + (store.artifacts / f"{reference}.json").write_text("{}") + with pytest.raises(ValueError, match="checksum mismatch"): + store.read(reference) + + +def test_roster_preserves_worker_capabilities_and_removes_only_its_registration( + tmp_path: Path, +) -> None: + store = FileArtifactStore(tmp_path) + session = WorkerRegistration("worker-1", True, 101, "queue") + ordinary = WorkerRegistration("worker-2", False, 102, "queue") + store.register_worker(session) + store.register_worker(ordinary) + assert store.list_workers("queue") == [session, ordinary] + assert store.list_workers("another-queue") == [] + with pytest.raises(ValueError, match="already registered"): + store.register_worker(session) + store.unregister_worker(replace(session, pid=999)) + assert store.list_workers("queue") == [session, ordinary] + store.unregister_worker(session) + store.unregister_worker(ordinary) + assert store.list_workers("queue") == [] diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py new file mode 100644 index 00000000..be5e3fcb --- /dev/null +++ b/tests/activity_sessions/integration_test.py @@ -0,0 +1,130 @@ +import asyncio +import os +import sys +import uuid +from pathlib import Path + +import pytest +from temporalio.api.enums.v1 import DescribeTaskQueueMode, TaskQueueType +from temporalio.api.taskqueue.v1 import TaskQueue, TaskQueueVersionSelection +from temporalio.api.workflowservice.v1 import DescribeTaskQueueRequest +from temporalio.client import Client + +from activity_sessions.storage import FileArtifactStore +from tests.helpers import assert_eventually + +pytestmark = pytest.mark.skipif( + os.getenv("TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE") != "1", + reason="requires the session SDK and custom server from temporal#12630", +) + + +@pytest.mark.parametrize( + "activity_worker_count", [1, 8], ids=["three-workers", "ten-workers"] +) +async def test_comparison_with_independent_worker_processes( + client: Client, + request: pytest.FixtureRequest, + tmp_path: Path, + activity_worker_count: int, +) -> None: + if request.config.getoption("--workflow-environment") in ("local", "time-skipping"): + pytest.fail("Use --workflow-environment host:port for the custom server") + task_queue = f"session-cache-{uuid.uuid4()}" + environment = { + **os.environ, + "TEMPORAL_ADDRESS": client.service_client.config.target_host, + "TEMPORAL_NAMESPACE": client.namespace, + } + processes = [] + logs = [] + worker_specs = [ + ("worker-1", False), + ("worker-2", False), + *[(f"worker-{index + 3}", True) for index in range(activity_worker_count)], + ] + try: + for identity, activity_only in worker_specs: + log = (tmp_path / f"{identity}.log").open("w") + logs.append(log) + args = [ + sys.executable, + "-m", + "activity_sessions.worker", + "--identity", + identity, + "--task-queue", + task_queue, + "--store-dir", + str(tmp_path), + ] + if activity_only: + args.append("--activity-only") + processes.append( + await asyncio.create_subprocess_exec( + *args, env=environment, stdout=log, stderr=asyncio.subprocess.STDOUT + ) + ) + + async def workers_are_polling() -> None: + for process in processes: + assert process.returncode is None, "\n".join( + path.read_text() for path in tmp_path.glob("*.log") + ) + assert len(FileArtifactStore(tmp_path).list_workers(task_queue)) == len( + worker_specs + ) + for kind, expected in [ + (TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW, {"worker-1", "worker-2"}), + ( + TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY, + {identity for identity, _ in worker_specs}, + ), + ]: + description = await client.workflow_service.describe_task_queue( + DescribeTaskQueueRequest( + namespace=client.namespace, + task_queue=TaskQueue(name=task_queue), + # Readiness must include pollers on every normal-queue partition. + api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, + task_queue_types=[kind], + versions=TaskQueueVersionSelection(unversioned=True), + report_pollers=True, + ) + ) + assert expected <= { + poller.identity + for version in description.versions_info.values() + for poller in version.types_info[kind].pollers + } + + await assert_eventually(workers_are_polling) + starter = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "activity_sessions.starter", + "--task-queue", + task_queue, + "--store-dir", + str(tmp_path), + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + processes.append(starter) + output, _ = await asyncio.wait_for(starter.communicate(), 60) + assert starter.returncode == 0, output.decode() + print(output.decode()) + finally: + for process in processes: + if process.returncode is None: + process.terminate() + for process in processes: + try: + await asyncio.wait_for(process.wait(), 15) + except asyncio.TimeoutError: + process.kill() + await process.wait() + for log in logs: + log.close() + assert FileArtifactStore(tmp_path).list_workers(task_queue) == [] diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py new file mode 100644 index 00000000..406b9dd5 --- /dev/null +++ b/tests/activity_sessions/report_test.py @@ -0,0 +1,45 @@ +from temporalio.api.history.v1 import HistoryEvent, WorkflowTaskStartedEventAttributes +from temporalio.client import WorkflowHistory + +from activity_sessions.models import StepResult, WorkerRegistration +from activity_sessions.starter import print_comparison, summarize_workers + + +def test_per_worker_report_includes_session_capability_and_idle_workers(capsys) -> None: + roster = [ + WorkerRegistration("worker-1", True, 101, "queue"), + WorkerRegistration("worker-2", False, 102, "queue"), + ] + results = [ + StepResult(1, "worker-1", True, False, 1, 1, "first-output"), + StepResult(2, "worker-1", True, True, 0, 1, "second-output"), + ] + summaries = summarize_workers(results, roster) + assert (summaries[0].steps, summaries[0].hits, summaries[0].misses) == (2, 1, 1) + assert (summaries[0].artifact_reads, summaries[0].artifact_writes) == (1, 2) + assert summaries[1].identity == "worker-2" + assert not summaries[1].session_enabled + assert summaries[1].steps == 0 + history = WorkflowHistory( + "workflow", + [ + HistoryEvent( + workflow_task_started_event_attributes=WorkflowTaskStartedEventAttributes( + identity="worker-1" + ) + ) + ], + ) + print_comparison(True, "run-1", results, history, roster) + output = capsys.readouterr().out + assert "Session support" in output + assert "worker-2" in output + assert "OFF" in output + assert "Verified:" in output + + +def test_disabled_comparison_does_not_require_scattered_placement(capsys) -> None: + results = [StepResult(1, "worker-1", True, False, 1, 1, "output")] + history = WorkflowHistory("workflow", []) + print_comparison(False, "run-1", results, history, []) + assert "placement is not guaranteed" in capsys.readouterr().out From f581244e94e8580ab2e5d9066b9aa452c49c676a Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 09:12:08 -0400 Subject: [PATCH 3/9] Simplify activity session cache demo --- README.md | 2 +- activity_sessions/.gitignore | 1 - activity_sessions/README.md | 186 +++++------------ activity_sessions/__init__.py | 10 + activity_sessions/activities.py | 69 ++----- activity_sessions/conversation.txt | 2 + activity_sessions/models.py | 41 ---- activity_sessions/starter.py | 216 +++++--------------- activity_sessions/storage.py | 77 ------- activity_sessions/worker.py | 68 +++--- activity_sessions/workflows.py | 50 ++--- pyproject.toml | 4 +- tests/activity_sessions/cache_test.py | 110 +++------- tests/activity_sessions/integration_test.py | 19 +- tests/activity_sessions/report_test.py | 60 ++---- 15 files changed, 237 insertions(+), 678 deletions(-) delete mode 100644 activity_sessions/.gitignore create mode 100644 activity_sessions/conversation.txt delete mode 100644 activity_sessions/models.py delete mode 100644 activity_sessions/storage.py diff --git a/README.md b/README.md index f32a56fc..36577af5 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Some examples require extra dependencies. See each sample's directory for specif * [hello_signal](hello/hello_signal.py) - Send signals to a workflow. * [hello update](hello/hello_update.py) - Send a request to and a response from a client to a workflow execution. -* [activity_sessions](activity_sessions) - Experimental workflow-affine activities reuse process-local caches instead of rereading shared artifacts. +* [activity_sessions](activity_sessions) - Experimental workflow-affine activities reuse worker-local conversation context. * [activity_worker](activity_worker) - Use Python activities from a workflow in another language. * [batch_sliding_window](batch_sliding_window) - Batch processing with a sliding window of child workflows. * [bedrock](bedrock) - Orchestrate a chatbot with Amazon Bedrock. diff --git a/activity_sessions/.gitignore b/activity_sessions/.gitignore deleted file mode 100644 index d0718296..00000000 --- a/activity_sessions/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.data/ diff --git a/activity_sessions/README.md b/activity_sessions/README.md index 5b10425f..a00771ba 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -1,174 +1,92 @@ -# Activity sessions: reuse worker-local conversation data +# Activity sessions and a worker-local cache -**Experimental demo.** Requires the Python SDK session branch -([sdk-python#1979](https://github.com/temporalio/sdk-python/pull/1979)) and a server -built from [temporal#12630](https://github.com/temporalio/temporal/pull/12630). -The ordinary released SDK and stock downloaded dev server are not substitutes. +This experimental sample needs the session SDK from +[sdk-python#1979](https://github.com/temporalio/sdk-python/pull/1979) and the server +from [temporal#12630](https://github.com/temporalio/temporal/pull/12630), with +`system.enableStickyActivityExecution` enabled for your namespace. -The same `ProcessConversation` workflow calls the same regular activity five times -sequentially. The starter runs it with activity affinity off, then on, without -changing the worker configuration. +The workflow is simply: -```python -session_options = ( - workflow.ActivitySessionOptions.workflow_session() - if request.affinity_enabled - else None -) +```text +conversation_step(1) → conversation_step(2) → conversation_step(3) ``` -Each step consumes the preceding step's output. Only a small content-addressed -reference passes through workflow history. Conversation data lives in a shared, -file-backed mock external store. Each worker process owns a private dictionary -cache, scoped by workflow Run ID and artifact reference. +Each worker process has its own `ConversationActivities` instance and dictionary. +The activity looks up conversation context by workflow Run ID. On a miss it reads +`conversation.txt`, our stand-in for loading context from an external source. +Subsequent activities on that process reuse the cached context. -- A miss reads the artifact from the shared store. -- Every step persists its output before returning its reference, then caches that - output locally. -- The next step on the same process can reuse the output without another read. -- A different process or a restarted worker can read the persisted output instead. - -The cache is not workflow state and is not reconstructed by replay. It is an -optimization, not the only copy of the conversation. This is not an implementation -of SDK `ExternalStorage`; it makes the avoided artifact reads explicit. - -## Prerequisites - -Start the custom server with `system.enableStickyActivityExecution` enabled for -your namespace. Create the namespace if necessary. For example, after starting a -fresh in-memory server: - -```bash -temporal --address localhost:7233 operator namespace create --namespace default -``` +The cache is not workflow state. The workflow only receives step number, worker +identity, session capability, and hit/miss. A new process can load the context +again from the file. Each comparison execution has a new Run ID and starts cold. -Build the session SDK checkout following its `poe build-develop` instructions. -From the samples repository root, use that checkout's interpreter so the released -SDK dependency in this repository does not replace the experimental SDK: +The only difference between the two runs is the **activity** option: -```bash -export SDK_PYTHON=/absolute/path/to/sdk-python/.venv/bin/python +```python +"session_options": ( + workflow.ActivitySessionOptions.workflow_session() + if affinity_enabled + else None +) ``` -All processes must use that same SDK build. The sample uses the repository's -standard `ClientConfig.load_client_connect_config()` configuration, defaulting to -`localhost:7233`. Authentication and namespace can be supplied via the existing -environment configuration mechanism. +## Run -## Start two worker processes +Build the session SDK with `poe build-develop`. From this repository root, in each +terminal, set `SDK_PYTHON` to that SDK checkout's `.venv/bin/python`. Use this +interpreter rather than letting `uv run` select the released SDK dependency. +Connection configuration uses the existing SDK environment/profile settings and +defaults to `localhost:7233`. -In terminal 1, from the repository root: +Start a separate worker process in each of two terminals: ```bash "$SDK_PYTHON" -m activity_sessions.worker --identity worker-1 -``` - -In terminal 2: - -```bash "$SDK_PYTHON" -m activity_sessions.worker --identity worker-2 ``` -Both workers register the workflow and activity, enable -`WorkerSessionOptions()`, use fixed activity capacity, and disable eager dispatch. -Each prints its PID and each activity's cache result as it runs. Each invocation -creates its own client, Temporal runtime, activity instance, and cache. - -The shared directory defaults to `activity_sessions/.data` and is ignored by Git. -Use the same `--store-dir` and `--task-queue` on every worker and the starter. -The filesystem must be shared; this mock setup is intended for processes on one -machine, not separate hosts. - -## Run the comparison - -In terminal 3: +Both register the same workflow and activity and enable `WorkerSessionOptions()`. +Eager activity dispatch is disabled. In another terminal: ```bash "$SDK_PYTHON" -m activity_sessions.starter ``` -Each execution has a different Run ID, so both start with a cold cache even when -the initial artifact has the same content. The starter prints: - -- Workflow-task worker identities obtained from history. -- Per-step executing identity, cache hit/miss, artifact reads, and artifact writes. -- A per-worker table including **Session support ON/OFF** and zero-execution rows. - -For example, with affinity enabled: - -```text -Step Activity worker Cache Artifact reads Artifact writes -1 worker-1 MISS 1 1 -2 worker-1 HIT 0 1 -3 worker-1 HIT 0 1 -4 worker-1 HIT 0 1 -5 worker-1 HIT 0 1 - -Worker Session support Steps Hits Misses Artifact reads Artifact writes -worker-1 ON 5 4 1 1 5 -worker-2 ON 0 0 0 0 0 -``` - -Every step still writes its output for durability. Affinity saves input reads; it -does not eliminate those writes. Counts exclude the starter's seed/verification -I/O and worker-roster I/O. - -**Ordinary routing may also colocate every activity.** The sample reports actual -placement and does not force alternation or assert a disabled run must have more -misses. Run again or use a larger pool for more observations: +The starter runs affinity off, then on, with the workers unchanged. It prints the +workflow worker from history, each activity's worker and cache result, and a +per-worker summary with **Session support ON/OFF**. -```bash -"$SDK_PYTHON" -m activity_sessions.starter --affinity off --steps 10 -"$SDK_PYTHON" -m activity_sessions.starter --affinity on --steps 10 -``` +With affinity on, expect one miss followed by two hits on the workflow owner. +Without affinity, context may be loaded on several workers. Ordinary routing may +also colocate every activity by chance; the sample reports the observed result. -## Optional mixed pool +## Optional activity-only workers -Keep the session-capable workers above, then start additional activity-only -processes with unique identities: +All workflow-capable workers must enable session support. To add normal activity +competition, start processes that register only the activity: ```bash "$SDK_PYTHON" -m activity_sessions.worker --identity worker-3 --activity-only +"$SDK_PYTHON" -m activity_sessions.starter --activity-workers worker-3 ``` -Repeat with `worker-4` through `worker-10` if desired. These workers register the -same activity on the same normal queue but do not run workflows or poll sticky -activity queues. Their table rows show **Session support OFF**. - -All workflow-capable workers must enable session support: the current protocol -does not select a session-capable worker based on the workflow input. - -## Shutdown and limitations - -Use Ctrl+C or SIGTERM for graceful shutdown. A small local roster records each -worker's configured identity, role, PID, and queue so idle workers can appear in -the table. It is not a health check. Graceful shutdown removes the registration; -a hard crash can leave a stale entry. Use a fresh `--store-dir` for a fresh demo -after a crash rather than trusting stale registrations. +The `--session-workers` and `--activity-workers` arguments identify known workers +for zero-execution rows. Capabilities for workers that actually execute activities +come from their results; this table is not a worker health check or registry. +Repeat the activity-only command with more unique identities for a larger pool. -This demonstrates the normal, healthy-worker path. It does not demonstrate -single-session lifetime isolation, stable destinations, worker-loss recovery, -sticky timeout fallback, or Continue-As-New placement. Cache entries are retained -for the process lifetime; production code needs eviction and resource limits. +Ctrl+C or SIGTERM shuts down a worker. No per-worker queues, cache registry, +artifact protocol, or external services are required. `--context-file` can select +another shared context file. This demonstrates healthy-worker affinity and cache +reuse, not lifetime isolation, failover, or physical queue provenance. -The result table observes worker colocation, not the physical queue of each poll. -No per-worker task queue names are managed by application code. +## Validation -## Tests +Cache/report tests do not need a server. The opt-in test runs actual worker +processes and the starter CLI against the custom server: ```bash "$SDK_PYTHON" -m pytest tests/activity_sessions -q -``` - -The cache/storage/report tests do not require a server. The opt-in SDK/server test -launches independent worker processes in three-worker and ten-worker configurations -(two session workers plus activity-only workers), runs the starter CLI, checks -both modes, and shuts down its own worker processes: - -```bash TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE=1 "$SDK_PYTHON" -m pytest \ tests/activity_sessions -q -s --workflow-environment localhost:7233 ``` - -This test requires the custom server, namespace gate, and session SDK. It is -skipped by default; it does not start or reconfigure the custom server. diff --git a/activity_sessions/__init__.py b/activity_sessions/__init__.py index 4185fae1..24ccc2f6 100644 --- a/activity_sessions/__init__.py +++ b/activity_sessions/__init__.py @@ -1 +1,11 @@ +from dataclasses import dataclass + TASK_QUEUE = "activity-session-cache" + + +@dataclass +class StepResult: + step: int + worker_identity: str + session_enabled: bool + cache_hit: bool diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py index f215cd3d..601f35f8 100644 --- a/activity_sessions/activities.py +++ b/activity_sessions/activities.py @@ -1,69 +1,30 @@ -import asyncio +from pathlib import Path from temporalio import activity -from temporalio.exceptions import ApplicationError -from activity_sessions.models import ( - ACTIVITY_NAME, - ConversationContext, - StepInput, - StepResult, -) -from activity_sessions.storage import FileArtifactStore +from activity_sessions import StepResult class ConversationActivities: def __init__( - self, - store: FileArtifactStore, - worker_identity: str, - session_enabled: bool, + self, worker_identity: str, session_enabled: bool, context_file: Path ) -> None: - self.store = store self.worker_identity = worker_identity self.session_enabled = session_enabled - self.cache: dict[tuple[str, str], ConversationContext] = {} + self.context_file = context_file + self.cache: dict[str, str] = {} - @activity.defn(name=ACTIVITY_NAME) - async def process_conversation_step(self, request: StepInput) -> StepResult: + @activity.defn + async def conversation_step(self, step: int) -> StepResult: run_id = activity.info().workflow_run_id - if run_id is None: - raise ApplicationError( - "This sample requires a workflow activity", non_retryable=True - ) - - cache_key = (run_id, request.input_ref) - context = self.cache.get(cache_key) - cache_hit = context is not None - artifact_reads = 0 - if context is None: - context = await asyncio.to_thread(self.store.read, request.input_ref) - artifact_reads += 1 - self.cache[cache_key] = context - - output = ConversationContext( - messages=[ - *context.messages, - f"Processed conversation step {request.step}.", - ], - completed_steps=[*context.completed_steps, request.step], - ) - # Persist before advertising the reference: another worker must be able to recover on a miss. - output_ref = await asyncio.to_thread(self.store.write, output) - artifact_writes = 1 - self.cache[(run_id, output_ref)] = output + assert run_id is not None + cache_hit = run_id in self.cache + if not cache_hit: + self.cache[run_id] = self.context_file.read_text() + context = self.cache[run_id] print( - f"[{self.worker_identity}] run={run_id} step={request.step} " - f"cache={'HIT' if cache_hit else 'MISS'} " - f"artifact_reads={artifact_reads} artifact_writes={artifact_writes}", + f"[{self.worker_identity}] step={step} " + f"cache={'HIT' if cache_hit else 'MISS'} context_size={len(context)}", flush=True, ) - return StepResult( - step=request.step, - worker_identity=self.worker_identity, - session_enabled=self.session_enabled, - cache_hit=cache_hit, - artifact_reads=artifact_reads, - artifact_writes=artifact_writes, - output_ref=output_ref, - ) + return StepResult(step, self.worker_identity, self.session_enabled, cache_hit) diff --git a/activity_sessions/conversation.txt b/activity_sessions/conversation.txt new file mode 100644 index 00000000..a66b6da2 --- /dev/null +++ b/activity_sessions/conversation.txt @@ -0,0 +1,2 @@ +Customer: I am planning a weekend trip and need help choosing a destination. +Agent context: The customer prefers train travel, museums, and vegetarian food. diff --git a/activity_sessions/models.py b/activity_sessions/models.py deleted file mode 100644 index 0a3c5ba4..00000000 --- a/activity_sessions/models.py +++ /dev/null @@ -1,41 +0,0 @@ -from dataclasses import dataclass, field - -ACTIVITY_NAME = "process_conversation_step" - - -@dataclass(frozen=True) -class ConversationRequest: - input_ref: str - affinity_enabled: bool - steps: int = 5 - - -@dataclass(frozen=True) -class StepInput: - step: int - input_ref: str - - -@dataclass(frozen=True) -class StepResult: - step: int - worker_identity: str - session_enabled: bool - cache_hit: bool - artifact_reads: int - artifact_writes: int - output_ref: str - - -@dataclass -class ConversationContext: - messages: list[str] - completed_steps: list[int] = field(default_factory=list) - - -@dataclass(frozen=True) -class WorkerRegistration: - identity: str - session_enabled: bool - pid: int - task_queue: str diff --git a/activity_sessions/starter.py b/activity_sessions/starter.py index 4c0672c0..502fac1e 100644 --- a/activity_sessions/starter.py +++ b/activity_sessions/starter.py @@ -2,213 +2,95 @@ import asyncio import uuid from collections.abc import Sequence -from dataclasses import dataclass from datetime import timedelta -from pathlib import Path -from temporalio.client import Client, WorkflowHistory +from temporalio.client import Client from temporalio.envconfig import ClientConfig -from activity_sessions import TASK_QUEUE -from activity_sessions.models import ( - ConversationContext, - ConversationRequest, - StepResult, - WorkerRegistration, -) -from activity_sessions.storage import FileArtifactStore +from activity_sessions import TASK_QUEUE, StepResult from activity_sessions.workflows import ProcessConversation -@dataclass -class WorkerSummary: - identity: str - session_enabled: bool - steps: int = 0 - hits: int = 0 - misses: int = 0 - artifact_reads: int = 0 - artifact_writes: int = 0 - - -def summarize_workers( - results: Sequence[StepResult], roster: Sequence[WorkerRegistration] -) -> list[WorkerSummary]: - summaries = { - item.identity: WorkerSummary(item.identity, item.session_enabled) - for item in roster - } - for result in results: - summary = summaries.setdefault( - result.worker_identity, - WorkerSummary(result.worker_identity, result.session_enabled), - ) - if summary.session_enabled != result.session_enabled: - raise ValueError( - f"Worker {summary.identity!r} changed session capability during the run" - ) - summary.steps += 1 - summary.hits += int(result.cache_hit) - summary.misses += int(not result.cache_hit) - summary.artifact_reads += result.artifact_reads - summary.artifact_writes += result.artifact_writes - return sorted(summaries.values(), key=lambda item: item.identity) - - -def format_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str: - all_rows = [headers, *rows] - widths = [max(len(row[index]) for row in all_rows) for index in range(len(headers))] - return "\n".join( - " ".join(value.ljust(width) for value, width in zip(row, widths)) - for row in all_rows - ) - - -def print_comparison( +def print_results( affinity_enabled: bool, - run_id: str, results: Sequence[StepResult], - history: WorkflowHistory, - roster: Sequence[WorkerRegistration], + workflow_workers: set[str], + known_workers: dict[str, bool], ) -> None: - owners = { - event.workflow_task_started_event_attributes.identity - for event in history.events - if event.HasField("workflow_task_started_event_attributes") - } print(f"\nActivity affinity: {'ON' if affinity_enabled else 'OFF'}") - print(f"Workflow Run ID: {run_id}") - print(f"Workflow-task worker(s): {', '.join(sorted(owners))}") - print( - format_table( - ["Step", "Activity worker", "Cache", "Artifact reads", "Artifact writes"], - [ - [ - str(result.step), - result.worker_identity, - "HIT" if result.cache_hit else "MISS", - str(result.artifact_reads), - str(result.artifact_writes), - ] - for result in results - ], - ) - ) - print("\nPer-worker totals for this run (idle rows use the local worker roster):") - print( - format_table( - [ - "Worker", - "Session support", - "Steps", - "Hits", - "Misses", - "Artifact reads", - "Artifact writes", - ], - [ - [ - item.identity, - "ON" if item.session_enabled else "OFF", - str(item.steps), - str(item.hits), - str(item.misses), - str(item.artifact_reads), - str(item.artifact_writes), - ] - for item in summarize_workers(results, roster) - ], + print(f"Workflow-task worker(s): {', '.join(sorted(workflow_workers))}") + print(f"{'Step':<6}{'Activity worker':<22}Cache") + for result in results: + print( + f"{result.step:<6}{result.worker_identity:<22}" + f"{'HIT' if result.cache_hit else 'MISS'}" ) - ) print( - f"Workers executing activities: {len({result.worker_identity for result in results})}; " - f"artifact reads: {sum(result.artifact_reads for result in results)}; " - f"artifact writes: {sum(result.artifact_writes for result in results)}" + f"\n{'Worker':<22}{'Session support':<18}{'Steps':<8}{'Hits':<8}Misses / context loads" ) - if affinity_enabled: - if len(owners) != 1 or any( - result.worker_identity not in owners for result in results - ): - raise RuntimeError( - "Session activities did not remain on one workflow owner" - ) - if results[0].cache_hit or any(not result.cache_hit for result in results[1:]): - raise RuntimeError( - "Expected a cold first step followed by local cache hits" - ) + roles = { + **known_workers, + **{item.worker_identity: item.session_enabled for item in results}, + } + for identity, session_enabled in sorted(roles.items()): + steps = [item for item in results if item.worker_identity == identity] + hits = sum(item.cache_hit for item in steps) print( - "Verified: activities stayed with the workflow owner; subsequent steps reused its cache." + f"{identity:<22}{'ON' if session_enabled else 'OFF':<18}" + f"{len(steps):<8}{hits:<8}{len(steps) - hits}" ) - elif len({result.worker_identity for result in results}) == 1: + if affinity_enabled: + assert len(results) == 3 + assert len(workflow_workers) == 1 + assert all(item.worker_identity in workflow_workers for item in results) + assert [item.cache_hit for item in results] == [False, True, True] + print("Verified: one context load, then two cache hits on the workflow worker.") + else: print( - "Ordinary routing colocated these activities by chance; placement is not guaranteed." + "Ordinary placement is not guaranteed; this run may also colocate by chance." ) async def run_comparison( - mode: str, steps: int, task_queue: str, store_directory: Path + mode: str, task_queue: str, known_workers: dict[str, bool] ) -> None: - if steps < 1: - raise ValueError("At least one step is required") config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config) - store = FileArtifactStore(store_directory) - roster = store.list_workers(task_queue) - if not any(item.session_enabled for item in roster): - raise RuntimeError( - "Start at least one session-capable worker using the same store and task queue" - ) - input_ref = store.write( - ConversationContext( - messages=["Hydrated conversation context from the mock external store."] - ) - ) - modes = [False, True] if mode == "compare" else [mode == "on"] - for affinity_enabled in modes: + for affinity_enabled in [False, True] if mode == "compare" else [mode == "on"]: handle = await client.start_workflow( ProcessConversation.run, - ConversationRequest(input_ref, affinity_enabled, steps), - id=f"conversation-{'affine' if affinity_enabled else 'ordinary'}-{uuid.uuid4()}", + affinity_enabled, + id=f"conversation-{uuid.uuid4()}", task_queue=task_queue, - execution_timeout=timedelta(minutes=2), + execution_timeout=timedelta(minutes=1), ) results = await handle.result() - if len(results) != steps: - raise RuntimeError(f"Expected {steps} step results, got {len(results)}") - final_context = store.read(results[-1].output_ref) - if final_context.completed_steps != list(range(1, steps + 1)): - raise RuntimeError( - "Persisted output does not contain the complete conversation pipeline" - ) - print_comparison( - affinity_enabled, - handle.first_execution_run_id or "", - results, - await handle.fetch_history(), - store.list_workers(task_queue), - ) - print( - "\nRead/write totals count activity artifact I/O, excluding starter and worker-roster I/O." - ) + history = await handle.fetch_history() + workflow_workers = { + event.workflow_task_started_event_attributes.identity + for event in history.events + if event.HasField("workflow_task_started_event_attributes") + } + print_results(affinity_enabled, results, workflow_workers, known_workers) def main() -> None: - parser = argparse.ArgumentParser( - description="Compare ordinary and workflow-session activity caches." - ) + parser = argparse.ArgumentParser() parser.add_argument( "--affinity", choices=["compare", "on", "off"], default="compare" ) - parser.add_argument("--steps", type=int, default=5) parser.add_argument("--task-queue", default=TASK_QUEUE) parser.add_argument( - "--store-dir", type=Path, default=Path("activity_sessions/.data") + "--session-workers", nargs="+", default=["worker-1", "worker-2"] ) + parser.add_argument("--activity-workers", nargs="*", default=[]) args = parser.parse_args() - asyncio.run( - run_comparison(args.affinity, args.steps, args.task_queue, args.store_dir) - ) + known_workers = { + **{identity: True for identity in args.session_workers}, + **{identity: False for identity in args.activity_workers}, + } + asyncio.run(run_comparison(args.affinity, args.task_queue, known_workers)) if __name__ == "__main__": diff --git a/activity_sessions/storage.py b/activity_sessions/storage.py deleted file mode 100644 index e328d687..00000000 --- a/activity_sessions/storage.py +++ /dev/null @@ -1,77 +0,0 @@ -import hashlib -import json -import os -import re -import tempfile -from contextlib import suppress -from dataclasses import asdict -from pathlib import Path - -from activity_sessions.models import ConversationContext, WorkerRegistration - - -class FileArtifactStore: - """A shared, file-backed stand-in for an external object store, not a cache.""" - - def __init__(self, directory: Path) -> None: - self.directory = directory - self.artifacts = directory / "artifacts" - self.workers = directory / "workers" - self.artifacts.mkdir(parents=True, exist_ok=True) - self.workers.mkdir(parents=True, exist_ok=True) - - def write(self, context: ConversationContext) -> str: - data = json.dumps(asdict(context), sort_keys=True).encode() - reference = hashlib.sha256(data).hexdigest() - self._atomic_write(self.artifacts / f"{reference}.json", data) - return reference - - def read(self, reference: str) -> ConversationContext: - if not re.fullmatch(r"[0-9a-f]{64}", reference): - raise ValueError("An artifact reference must be a SHA-256 digest") - data = (self.artifacts / f"{reference}.json").read_bytes() - if hashlib.sha256(data).hexdigest() != reference: - raise ValueError(f"Artifact checksum mismatch: {reference}") - return ConversationContext(**json.loads(data)) - - def register_worker(self, worker: WorkerRegistration) -> None: - path = ( - self.workers - / f"{hashlib.sha256(worker.identity.encode()).hexdigest()}.json" - ) - if path.exists(): - raise ValueError( - f"{worker.identity!r} is already registered. Use a unique identity " - "or a fresh store directory if a previous process crashed." - ) - self._atomic_write(path, json.dumps(asdict(worker)).encode()) - - def unregister_worker(self, worker: WorkerRegistration) -> None: - path = ( - self.workers - / f"{hashlib.sha256(worker.identity.encode()).hexdigest()}.json" - ) - with suppress(FileNotFoundError): - if WorkerRegistration(**json.loads(path.read_bytes())) == worker: - path.unlink() - - def list_workers(self, task_queue: str) -> list[WorkerRegistration]: - workers = [] - for path in self.workers.glob("*.json"): - with suppress(FileNotFoundError): - worker = WorkerRegistration(**json.loads(path.read_bytes())) - if worker.task_queue == task_queue: - workers.append(worker) - return sorted(workers, key=lambda worker: worker.identity) - - @staticmethod - def _atomic_write(path: Path, data: bytes) -> None: - # Readers in other processes must never observe a partially written artifact. - with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as output: - temporary = Path(output.name) - try: - output.write(data) - output.close() - os.replace(temporary, path) - finally: - temporary.unlink(missing_ok=True) diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index 7bd515db..96e2b9cc 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -3,80 +3,62 @@ import os import signal from contextlib import suppress -from datetime import timedelta from pathlib import Path from temporalio.client import Client from temporalio.envconfig import ClientConfig -from temporalio.worker import PollerBehaviorSimpleMaximum, Worker, WorkerSessionOptions +from temporalio.worker import Worker, WorkerSessionOptions from activity_sessions import TASK_QUEUE from activity_sessions.activities import ConversationActivities -from activity_sessions.models import WorkerRegistration -from activity_sessions.storage import FileArtifactStore from activity_sessions.workflows import ProcessConversation async def run_worker( - identity: str, activity_only: bool, task_queue: str, store_directory: Path + identity: str, activity_only: bool, task_queue: str, context_file: Path ) -> None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config, identity=identity) session_enabled = not activity_only - store = FileArtifactStore(store_directory) - activities = ConversationActivities(store, identity, session_enabled) - registration = WorkerRegistration( - identity, session_enabled, os.getpid(), task_queue - ) + activities = ConversationActivities(identity, session_enabled, context_file) stop = asyncio.Event() - loop = asyncio.get_running_loop() for signum in (signal.SIGINT, signal.SIGTERM): with suppress(NotImplementedError): - loop.add_signal_handler(signum, stop.set) - - store.register_worker(registration) - try: - async with Worker( - client, - task_queue=task_queue, - workflows=[ProcessConversation] if session_enabled else [], - activities=[activities.process_conversation_step], - session_worker_options=WorkerSessionOptions() if session_enabled else None, - disable_eager_activity_execution=True, - max_cached_workflows=100 if session_enabled else 0, - max_concurrent_activities=2, - workflow_task_poller_behavior=PollerBehaviorSimpleMaximum(2), - activity_task_poller_behavior=PollerBehaviorSimpleMaximum(2), - sticky_queue_schedule_to_start_timeout=timedelta(minutes=1), - ): - print( - f"Worker {identity} pid={os.getpid()} queue={task_queue} " - f"session_polling={'ON' if session_enabled else 'OFF'} " - f"role={'workflow+activity' if session_enabled else 'activity-only'} " - f"store={store_directory.resolve()}", - flush=True, - ) - await stop.wait() - finally: - store.unregister_worker(registration) + asyncio.get_running_loop().add_signal_handler(signum, stop.set) + async with Worker( + client, + task_queue=task_queue, + workflows=[ProcessConversation] if session_enabled else [], + activities=[activities.conversation_step], + session_worker_options=WorkerSessionOptions() if session_enabled else None, + disable_eager_activity_execution=True, + max_cached_workflows=100 if session_enabled else 0, + max_concurrent_activities=2, + ): + print( + f"{identity} pid={os.getpid()} queue={task_queue} " + f"session_support={'ON' if session_enabled else 'OFF'}", + flush=True, + ) + await stop.wait() def main() -> None: - parser = argparse.ArgumentParser( - description="Run one process with its own activity cache." - ) + parser = argparse.ArgumentParser() parser.add_argument("--identity", required=True) parser.add_argument("--activity-only", action="store_true") parser.add_argument("--task-queue", default=TASK_QUEUE) parser.add_argument( - "--store-dir", type=Path, default=Path("activity_sessions/.data") + "--context-file", + type=Path, + default=Path(__file__).with_name("conversation.txt"), ) args = parser.parse_args() try: asyncio.run( run_worker( - args.identity, args.activity_only, args.task_queue, args.store_dir + args.identity, args.activity_only, args.task_queue, args.context_file ) ) except KeyboardInterrupt: diff --git a/activity_sessions/workflows.py b/activity_sessions/workflows.py index 5a0e4b7a..3d745d6b 100644 --- a/activity_sessions/workflows.py +++ b/activity_sessions/workflows.py @@ -2,40 +2,30 @@ from temporalio import workflow from temporalio.common import RetryPolicy -from temporalio.exceptions import ApplicationError -from activity_sessions.models import ( - ACTIVITY_NAME, - ConversationRequest, - StepInput, - StepResult, -) +from activity_sessions import StepResult @workflow.defn class ProcessConversation: @workflow.run - async def run(self, request: ConversationRequest) -> list[StepResult]: - if request.steps < 1: - raise ApplicationError( - "At least one conversation step is required", non_retryable=True - ) - session_options = ( - workflow.ActivitySessionOptions.workflow_session() - if request.affinity_enabled - else None + async def run(self, affinity_enabled: bool) -> list[StepResult]: + options: workflow.ActivityConfig = { + "start_to_close_timeout": timedelta(seconds=30), + "retry_policy": RetryPolicy(maximum_attempts=1), + "session_options": ( + workflow.ActivitySessionOptions.workflow_session() + if affinity_enabled + else None + ), + } + first = await workflow.execute_activity( + "conversation_step", 1, result_type=StepResult, **options ) - input_ref = request.input_ref - results = [] - for step in range(1, request.steps + 1): - result = await workflow.execute_activity( - ACTIVITY_NAME, - StepInput(step=step, input_ref=input_ref), - result_type=StepResult, - session_options=session_options, - start_to_close_timeout=timedelta(seconds=30), - retry_policy=RetryPolicy(maximum_attempts=1), - ) - results.append(result) - input_ref = result.output_ref - return results + second = await workflow.execute_activity( + "conversation_step", 2, result_type=StepResult, **options + ) + third = await workflow.execute_activity( + "conversation_step", 3, result_type=StepResult, **options + ) + return [first, second, third] diff --git a/pyproject.toml b/pyproject.toml index d08665ab..e44a4487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,11 +88,11 @@ constraint-dependencies = [ allow-direct-references = true [tool.hatch.build.targets.sdist] -include = ["./**/*.py"] +include = ["./**/*.py", "./activity_sessions/conversation.txt"] exclude = ["lambda_worker/**"] [tool.hatch.build.targets.wheel] -include = ["./**/*.py"] +include = ["./**/*.py", "./activity_sessions/conversation.txt"] exclude = ["lambda_worker/**"] packages = [ "activity_sessions", diff --git a/tests/activity_sessions/cache_test.py b/tests/activity_sessions/cache_test.py index b1f69539..73b0a8f6 100644 --- a/tests/activity_sessions/cache_test.py +++ b/tests/activity_sessions/cache_test.py @@ -2,97 +2,45 @@ from pathlib import Path from unittest.mock import patch -import pytest from temporalio.testing import ActivityEnvironment from activity_sessions.activities import ConversationActivities -from activity_sessions.models import ConversationContext, StepInput, WorkerRegistration -from activity_sessions.storage import FileArtifactStore -async def test_next_step_reuses_locally_cached_output(tmp_path: Path) -> None: - store = FileArtifactStore(tmp_path) - initial_ref = store.write(ConversationContext(["Conversation context"])) - activities = ConversationActivities(store, "worker-1", True) +async def test_same_worker_loads_context_once() -> None: + activities = ConversationActivities("worker-1", True, Path("context.txt")) env = ActivityEnvironment() env.info = replace(env.info, workflow_run_id="run-1") - with ( - patch.object(store, "read", wraps=store.read) as reads, - patch.object(store, "write", wraps=store.write) as writes, - ): - first = await env.run( - activities.process_conversation_step, StepInput(1, initial_ref) - ) - second = await env.run( - activities.process_conversation_step, StepInput(2, first.output_ref) - ) - assert not first.cache_hit - assert second.cache_hit - assert reads.call_count == first.artifact_reads + second.artifact_reads == 1 - assert writes.call_count == first.artifact_writes + second.artifact_writes == 2 - assert store.read(second.output_ref).completed_steps == [1, 2] - assert store.read(initial_ref).completed_steps == [] - - -async def test_new_worker_recovers_output_from_external_store(tmp_path: Path) -> None: - store = FileArtifactStore(tmp_path) - initial_ref = store.write(ConversationContext(["Conversation context"])) - producer = ConversationActivities(store, "worker-1", True) + with patch.object(Path, "read_text", return_value="Conversation context") as read: + first = await env.run(activities.conversation_step, 1) + second = await env.run(activities.conversation_step, 2) + third = await env.run(activities.conversation_step, 3) + assert [first.cache_hit, second.cache_hit, third.cache_hit] == [False, True, True] + read.assert_called_once() + + +async def test_other_worker_has_its_own_cache(tmp_path: Path) -> None: + context_file = tmp_path / "context.txt" + context_file.write_text("Conversation context") + first_worker = ConversationActivities("worker-1", True, context_file) + other_worker = ConversationActivities("worker-2", False, context_file) env = ActivityEnvironment() env.info = replace(env.info, workflow_run_id="run-1") - first = await env.run(producer.process_conversation_step, StepInput(1, initial_ref)) + first = await env.run(first_worker.conversation_step, 1) + second = await env.run(other_worker.conversation_step, 2) + third = await env.run(first_worker.conversation_step, 3) + assert [first.cache_hit, second.cache_hit, third.cache_hit] == [False, False, True] + assert first_worker.cache is not other_worker.cache + assert other_worker.cache["run-1"] == "Conversation context" - consumer_store = FileArtifactStore(tmp_path) - consumer = ConversationActivities(consumer_store, "worker-2", False) - with patch.object(consumer_store, "read", wraps=consumer_store.read) as reads: - second = await env.run( - consumer.process_conversation_step, StepInput(2, first.output_ref) - ) - assert not second.cache_hit - assert reads.call_count == second.artifact_reads == 1 - assert not second.session_enabled - assert consumer_store.read(second.output_ref).completed_steps == [1, 2] - -async def test_new_workflow_run_starts_cold_on_same_worker(tmp_path: Path) -> None: - store = FileArtifactStore(tmp_path) - initial_ref = store.write(ConversationContext(["Conversation context"])) - activities = ConversationActivities(store, "worker-1", True) +async def test_new_run_starts_cold_on_same_worker() -> None: + activities = ConversationActivities("worker-1", True, Path("context.txt")) env = ActivityEnvironment() - env.info = replace(env.info, workflow_run_id="run-1") - await env.run(activities.process_conversation_step, StepInput(1, initial_ref)) - env.info = replace(env.info, workflow_run_id="run-2") - result = await env.run( - activities.process_conversation_step, StepInput(1, initial_ref) - ) + with patch.object(Path, "read_text", return_value="Conversation context") as read: + env.info = replace(env.info, workflow_run_id="run-1") + await env.run(activities.conversation_step, 1) + env.info = replace(env.info, workflow_run_id="run-2") + result = await env.run(activities.conversation_step, 1) assert not result.cache_hit - assert result.artifact_reads == 1 - - -def test_artifact_integrity_and_safe_references(tmp_path: Path) -> None: - store = FileArtifactStore(tmp_path) - reference = store.write(ConversationContext(["Conversation context"])) - with pytest.raises(ValueError, match="SHA-256"): - store.read("../outside-store") - (store.artifacts / f"{reference}.json").write_text("{}") - with pytest.raises(ValueError, match="checksum mismatch"): - store.read(reference) - - -def test_roster_preserves_worker_capabilities_and_removes_only_its_registration( - tmp_path: Path, -) -> None: - store = FileArtifactStore(tmp_path) - session = WorkerRegistration("worker-1", True, 101, "queue") - ordinary = WorkerRegistration("worker-2", False, 102, "queue") - store.register_worker(session) - store.register_worker(ordinary) - assert store.list_workers("queue") == [session, ordinary] - assert store.list_workers("another-queue") == [] - with pytest.raises(ValueError, match="already registered"): - store.register_worker(session) - store.unregister_worker(replace(session, pid=999)) - assert store.list_workers("queue") == [session, ordinary] - store.unregister_worker(session) - store.unregister_worker(ordinary) - assert store.list_workers("queue") == [] + assert read.call_count == 2 diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index be5e3fcb..9b3af4d2 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -10,7 +10,6 @@ from temporalio.api.workflowservice.v1 import DescribeTaskQueueRequest from temporalio.client import Client -from activity_sessions.storage import FileArtifactStore from tests.helpers import assert_eventually pytestmark = pytest.mark.skipif( @@ -31,6 +30,8 @@ async def test_comparison_with_independent_worker_processes( if request.config.getoption("--workflow-environment") in ("local", "time-skipping"): pytest.fail("Use --workflow-environment host:port for the custom server") task_queue = f"session-cache-{uuid.uuid4()}" + context_file = tmp_path / "conversation.txt" + context_file.write_text("Conversation context") environment = { **os.environ, "TEMPORAL_ADDRESS": client.service_client.config.target_host, @@ -55,8 +56,8 @@ async def test_comparison_with_independent_worker_processes( identity, "--task-queue", task_queue, - "--store-dir", - str(tmp_path), + "--context-file", + str(context_file), ] if activity_only: args.append("--activity-only") @@ -71,9 +72,6 @@ async def workers_are_polling() -> None: assert process.returncode is None, "\n".join( path.read_text() for path in tmp_path.glob("*.log") ) - assert len(FileArtifactStore(tmp_path).list_workers(task_queue)) == len( - worker_specs - ) for kind, expected in [ (TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW, {"worker-1", "worker-2"}), ( @@ -105,8 +103,11 @@ async def workers_are_polling() -> None: "activity_sessions.starter", "--task-queue", task_queue, - "--store-dir", - str(tmp_path), + "--session-workers", + "worker-1", + "worker-2", + "--activity-workers", + *[identity for identity, activity_only in worker_specs if activity_only], env=environment, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, @@ -127,4 +128,4 @@ async def workers_are_polling() -> None: await process.wait() for log in logs: log.close() - assert FileArtifactStore(tmp_path).list_workers(task_queue) == [] + assert all(process.returncode == 0 for process in processes) diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py index 406b9dd5..a5434a5a 100644 --- a/tests/activity_sessions/report_test.py +++ b/tests/activity_sessions/report_test.py @@ -1,45 +1,29 @@ -from temporalio.api.history.v1 import HistoryEvent, WorkflowTaskStartedEventAttributes -from temporalio.client import WorkflowHistory +from activity_sessions import StepResult +from activity_sessions.starter import print_results -from activity_sessions.models import StepResult, WorkerRegistration -from activity_sessions.starter import print_comparison, summarize_workers - -def test_per_worker_report_includes_session_capability_and_idle_workers(capsys) -> None: - roster = [ - WorkerRegistration("worker-1", True, 101, "queue"), - WorkerRegistration("worker-2", False, 102, "queue"), - ] - results = [ - StepResult(1, "worker-1", True, False, 1, 1, "first-output"), - StepResult(2, "worker-1", True, True, 0, 1, "second-output"), - ] - summaries = summarize_workers(results, roster) - assert (summaries[0].steps, summaries[0].hits, summaries[0].misses) == (2, 1, 1) - assert (summaries[0].artifact_reads, summaries[0].artifact_writes) == (1, 2) - assert summaries[1].identity == "worker-2" - assert not summaries[1].session_enabled - assert summaries[1].steps == 0 - history = WorkflowHistory( - "workflow", +def test_report_includes_idle_worker_and_session_setting(capsys) -> None: + print_results( + True, [ - HistoryEvent( - workflow_task_started_event_attributes=WorkflowTaskStartedEventAttributes( - identity="worker-1" - ) - ) + StepResult(1, "worker-1", True, False), + StepResult(2, "worker-1", True, True), + StepResult(3, "worker-1", True, True), ], + {"worker-1"}, + {"worker-1": True, "worker-2": False}, ) - print_comparison(True, "run-1", results, history, roster) - output = capsys.readouterr().out - assert "Session support" in output - assert "worker-2" in output - assert "OFF" in output - assert "Verified:" in output + lines = capsys.readouterr().out.splitlines() + assert any(line.split() == ["worker-1", "ON", "3", "2", "1"] for line in lines) + assert any(line.split() == ["worker-2", "OFF", "0", "0", "0"] for line in lines) + assert any("Verified:" in line for line in lines) -def test_disabled_comparison_does_not_require_scattered_placement(capsys) -> None: - results = [StepResult(1, "worker-1", True, False, 1, 1, "output")] - history = WorkflowHistory("workflow", []) - print_comparison(False, "run-1", results, history, []) - assert "placement is not guaranteed" in capsys.readouterr().out +def test_ordinary_routing_may_colocate_by_chance(capsys) -> None: + print_results( + False, + [StepResult(step, "worker-1", True, step != 1) for step in (1, 2, 3)], + {"worker-1"}, + {"worker-1": True}, + ) + assert "may also colocate by chance" in capsys.readouterr().out From b5d8db23f496b970be65b02c7ea4b25e11ae126f Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 09:49:44 -0400 Subject: [PATCH 4/9] Clarify cache demo output and worker lifecycle --- activity_sessions/README.md | 5 +++-- activity_sessions/activities.py | 4 ++-- activity_sessions/starter.py | 11 +++++++++-- activity_sessions/worker.py | 22 ++++++++------------- tests/activity_sessions/integration_test.py | 9 +++++++-- tests/activity_sessions/report_test.py | 1 + 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/activity_sessions/README.md b/activity_sessions/README.md index a00771ba..ed7a7953 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -75,8 +75,9 @@ for zero-execution rows. Capabilities for workers that actually execute activiti come from their results; this table is not a worker health check or registry. Repeat the activity-only command with more unique identities for a larger pool. -Ctrl+C or SIGTERM shuts down a worker. No per-worker queues, cache registry, -artifact protocol, or external services are required. `--context-file` can select +Ctrl+C stops a worker using the SDK's normal run/shutdown lifecycle. No per-worker +queues, cache registry, artifact protocol, or external services are required. +`--context-file` can select another shared context file. This demonstrates healthy-worker affinity and cache reuse, not lifetime isolation, failover, or physical queue provenance. diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py index 601f35f8..34847cd8 100644 --- a/activity_sessions/activities.py +++ b/activity_sessions/activities.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import cast from temporalio import activity @@ -16,8 +17,7 @@ def __init__( @activity.defn async def conversation_step(self, step: int) -> StepResult: - run_id = activity.info().workflow_run_id - assert run_id is not None + run_id = cast(str, activity.info().workflow_run_id) cache_hit = run_id in self.cache if not cache_hit: self.cache[run_id] = self.context_file.read_text() diff --git a/activity_sessions/starter.py b/activity_sessions/starter.py index 502fac1e..7329ea8f 100644 --- a/activity_sessions/starter.py +++ b/activity_sessions/starter.py @@ -19,10 +19,11 @@ def print_results( ) -> None: print(f"\nActivity affinity: {'ON' if affinity_enabled else 'OFF'}") print(f"Workflow-task worker(s): {', '.join(sorted(workflow_workers))}") - print(f"{'Step':<6}{'Activity worker':<22}Cache") + print(f"{'Step':<6}{'Activity worker':<22}{'Session support':<18}Cache") for result in results: print( f"{result.step:<6}{result.worker_identity:<22}" + f"{'ON' if result.session_enabled else 'OFF':<18}" f"{'HIT' if result.cache_hit else 'MISS'}" ) print( @@ -57,7 +58,13 @@ async def run_comparison( config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config) - for affinity_enabled in [False, True] if mode == "compare" else [mode == "on"]: + if mode == "compare": + affinity_modes = [False, True] + elif mode == "on": + affinity_modes = [True] + else: + affinity_modes = [False] + for affinity_enabled in affinity_modes: handle = await client.start_workflow( ProcessConversation.run, affinity_enabled, diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index 96e2b9cc..68b91fa0 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -1,8 +1,6 @@ import argparse import asyncio import os -import signal -from contextlib import suppress from pathlib import Path from temporalio.client import Client @@ -22,11 +20,7 @@ async def run_worker( client = await Client.connect(**config, identity=identity) session_enabled = not activity_only activities = ConversationActivities(identity, session_enabled, context_file) - stop = asyncio.Event() - for signum in (signal.SIGINT, signal.SIGTERM): - with suppress(NotImplementedError): - asyncio.get_running_loop().add_signal_handler(signum, stop.set) - async with Worker( + worker = Worker( client, task_queue=task_queue, workflows=[ProcessConversation] if session_enabled else [], @@ -35,13 +29,13 @@ async def run_worker( disable_eager_activity_execution=True, max_cached_workflows=100 if session_enabled else 0, max_concurrent_activities=2, - ): - print( - f"{identity} pid={os.getpid()} queue={task_queue} " - f"session_support={'ON' if session_enabled else 'OFF'}", - flush=True, - ) - await stop.wait() + ) + print( + f"{identity} pid={os.getpid()} queue={task_queue} " + f"session_support={'ON' if session_enabled else 'OFF'}", + flush=True, + ) + await worker.run() def main() -> None: diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index 9b3af4d2..341972af 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -1,5 +1,6 @@ import asyncio import os +import signal import sys import uuid from pathlib import Path @@ -119,7 +120,10 @@ async def workers_are_polling() -> None: finally: for process in processes: if process.returncode is None: - process.terminate() + if sys.platform == "win32": + process.terminate() + else: + process.send_signal(signal.SIGINT) for process in processes: try: await asyncio.wait_for(process.wait(), 15) @@ -128,4 +132,5 @@ async def workers_are_polling() -> None: await process.wait() for log in logs: log.close() - assert all(process.returncode == 0 for process in processes) + if sys.platform != "win32": + assert all(process.returncode == 0 for process in processes) diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py index a5434a5a..85570872 100644 --- a/tests/activity_sessions/report_test.py +++ b/tests/activity_sessions/report_test.py @@ -14,6 +14,7 @@ def test_report_includes_idle_worker_and_session_setting(capsys) -> None: {"worker-1": True, "worker-2": False}, ) lines = capsys.readouterr().out.splitlines() + assert any(line.split() == ["1", "worker-1", "ON", "MISS"] for line in lines) assert any(line.split() == ["worker-1", "ON", "3", "2", "1"] for line in lines) assert any(line.split() == ["worker-2", "OFF", "0", "0", "0"] for line in lines) assert any("Verified:" in line for line in lines) From ae907e5db0e331a30cda26f9ae35d0a0c2d63775 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 10:17:47 -0400 Subject: [PATCH 5/9] Add configurable worker pool and Rich reports --- activity_sessions/README.md | 142 +++++++++++------ activity_sessions/__init__.py | 15 ++ activity_sessions/activities.py | 1 + activity_sessions/display.py | 92 +++++++++++ activity_sessions/pool.py | 99 ++++++++++++ activity_sessions/starter.py | 119 +++++++------- pyproject.toml | 1 + tests/activity_sessions/integration_test.py | 163 +++++++++----------- tests/activity_sessions/report_test.py | 88 ++++++++--- uv.lock | 8 +- 10 files changed, 513 insertions(+), 215 deletions(-) create mode 100644 activity_sessions/display.py create mode 100644 activity_sessions/pool.py diff --git a/activity_sessions/README.md b/activity_sessions/README.md index ed7a7953..bb47b09a 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -5,22 +5,19 @@ This experimental sample needs the session SDK from from [temporal#12630](https://github.com/temporalio/temporal/pull/12630), with `system.enableStickyActivityExecution` enabled for your namespace. -The workflow is simply: +The workflow remains simply: ```text conversation_step(1) → conversation_step(2) → conversation_step(3) ``` -Each worker process has its own `ConversationActivities` instance and dictionary. -The activity looks up conversation context by workflow Run ID. On a miss it reads -`conversation.txt`, our stand-in for loading context from an external source. -Subsequent activities on that process reuse the cached context. +Each worker process owns one activity instance and dictionary cache, keyed by +workflow Run ID. A miss reads `conversation.txt`, a stand-in for an external +conversation source. Further activities on the same process reuse that context. +The dictionary and context are not workflow state. A new execution starts cold, +and a new process can reload the context from the file. -The cache is not workflow state. The workflow only receives step number, worker -identity, session capability, and hit/miss. A new process can load the context -again from the file. Each comparison execution has a new Run ID and starts cold. - -The only difference between the two runs is the **activity** option: +Only the activity option changes between the two runs: ```python "session_options": ( @@ -30,64 +27,121 @@ The only difference between the two runs is the **activity** option: ) ``` -## Run +## Setup + +Build the session SDK with `poe build-develop`. From the samples repository root, +set `SDK_PYTHON` in each terminal to that SDK checkout's `.venv/bin/python`, then +install the sample's Rich display dependency into that environment: + +```bash +export SDK_PYTHON=/absolute/path/to/sdk-python/.venv/bin/python +uv pip install --python "$SDK_PYTHON" 'rich>=13,<15' +``` + +Use this interpreter rather than allowing `uv run` to select the released SDK. +Rich is also declared in this repository's `activity-sessions` dependency group. +The existing SDK environment/profile configuration selects the server and +namespace, defaulting to `localhost:7233` and `default`. + +## Launch a pool + +In one terminal, choose the worker counts: + +```bash +# Ten session-capable workers, each able to run workflows and activities: +"$SDK_PYTHON" -m activity_sessions.pool --session-workers 10 + +# Alternatively, one session-capable worker and nine normal activity-only workers: +"$SDK_PYTHON" -m activity_sessions.pool --session-workers 1 --activity-workers 9 +``` + +Run only one of these commands at a time. Each child is a fresh Python process +with its own client, runtime, activity instance, and cache. Worker names are +`worker-1` through `worker-N`; session workers come first. Ctrl+C stops the whole +pool, and a child exiting unexpectedly stops the pool rather than silently +leaving a smaller fleet. -Build the session SDK with `poe build-develop`. From this repository root, in each -terminal, set `SDK_PYTHON` to that SDK checkout's `.venv/bin/python`. Use this -interpreter rather than letting `uv run` select the released SDK dependency. -Connection configuration uses the existing SDK environment/profile settings and -defaults to `localhost:7233`. +All workers use the same logical task queue. Session workers enable +`WorkerSessionOptions()`; activity-only workers register no workflows. This +prevents a workflow requiring session activities from landing on an unsupported +worker. Eager activity execution is disabled. -Start a separate worker process in each of two terminals: +The default pool is two session workers. A single process can still be started +manually: ```bash "$SDK_PYTHON" -m activity_sessions.worker --identity worker-1 -"$SDK_PYTHON" -m activity_sessions.worker --identity worker-2 ``` -Both register the same workflow and activity and enable `WorkerSessionOptions()`. -Eager activity dispatch is disabled. In another terminal: +`--activity-only`, `--task-queue`, and `--context-file` are available on the worker. +The pool also accepts `--task-queue` and `--context-file`. + +## Run workflows + +In another terminal, use the same worker counts: ```bash -"$SDK_PYTHON" -m activity_sessions.starter +# Match the ten-session-worker pool; run ten workflows concurrently per mode: +"$SDK_PYTHON" -m activity_sessions.starter --session-workers 10 --workflows 10 + +# Or match the one-session/nine-activity-worker pool: +"$SDK_PYTHON" -m activity_sessions.starter \ + --session-workers 1 --activity-workers 9 --workflows 10 ``` -The starter runs affinity off, then on, with the workers unchanged. It prints the -workflow worker from history, each activity's worker and cache result, and a -per-worker summary with **Session support ON/OFF**. +The default `--affinity compare` runs the ordinary batch first, then the session +batch, without changing worker configuration. **`--workflows 10` means ten per +mode: twenty executions total in comparison mode.** `--affinity on` or `off` +runs only that mode. Every workflow still executes exactly three activities +sequentially. + +Rich renders: + +1. A worker-configuration table with roles and session support. +2. An execution table showing workflow ID, workflow owner, step, activity worker, + that worker's session support, and colored cache hit/miss. +3. Per-worker batch totals, including idle workers. -With affinity on, expect one miss followed by two hits on the workflow owner. -Without affinity, context may be loaded on several workers. Ordinary routing may -also colocate every activity by chance; the sample reports the observed result. +Configuration and idle rows come from the supplied counts, not a health check or +registry. Executing workers' capabilities come from activity results. -## Optional activity-only workers +With affinity on, each workflow should miss once and hit twice on its owner. +Ordinary routing can scatter activities, or colocate them by chance; the sample +reports the actual result rather than forcing alternation. -All workflow-capable workers must enable session support. To add normal activity -competition, start processes that register only the activity: +## Observe in Temporal UI + +A standalone `temporal-server` does not start a UI. Its `7243` port is the HTTP +API, not the UI. For a local server on macOS with Docker Desktop, a separate UI +can be started with: ```bash -"$SDK_PYTHON" -m activity_sessions.worker --identity worker-3 --activity-only -"$SDK_PYTHON" -m activity_sessions.starter --activity-workers worker-3 +docker run --rm --name temporal-session-ui \ + -p 127.0.0.1:8080:8080 \ + -e TEMPORAL_ADDRESS=host.docker.internal:7233 \ + temporalio/ui:latest ``` -The `--session-workers` and `--activity-workers` arguments identify known workers -for zero-execution rows. Capabilities for workers that actually execute activities -come from their results; this table is not a worker health check or registry. -Repeat the activity-only command with more unique identities for a larger pool. +Open , select your namespace, and find the workflow IDs +shown in the terminal. This container connects to the custom server; it does not +launch a stock server. Linux Docker host connectivity may require different +network configuration. -Ctrl+C stops a worker using the SDK's normal run/shutdown lifecycle. No per-worker -queues, cache registry, artifact protocol, or external services are required. -`--context-file` can select -another shared context file. This demonstrates healthy-worker affinity and cache -reuse, not lifetime isolation, failover, or physical queue provenance. +## Scope and validation -## Validation +This demonstrates healthy-worker affinity and cache reuse, not lifetime +isolation, failover, or physical queue provenance. Cache entries live for the +worker process lifetime; production caches need resource limits and eviction. +There are no per-worker queues, cache registry, or artifact protocol. -Cache/report tests do not need a server. The opt-in test runs actual worker -processes and the starter CLI against the custom server: +Unit tests do not require a server. Opt-in tests run the actual pool/starter CLIs +with three workers, ten session workers, and one session/nine activity workers: ```bash "$SDK_PYTHON" -m pytest tests/activity_sessions -q TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE=1 "$SDK_PYTHON" -m pytest \ tests/activity_sessions -q -s --workflow-environment localhost:7233 ``` + +The integration tests use the existing custom server; they do not start or +reconfigure it. diff --git a/activity_sessions/__init__.py b/activity_sessions/__init__.py index 24ccc2f6..96a41883 100644 --- a/activity_sessions/__init__.py +++ b/activity_sessions/__init__.py @@ -3,6 +3,21 @@ TASK_QUEUE = "activity-session-cache" +def worker_configuration( + session_workers: int, activity_workers: int +) -> dict[str, bool]: + if ( + session_workers < 0 + or activity_workers < 0 + or session_workers + activity_workers == 0 + ): + raise ValueError("Worker counts must be nonnegative, with at least one worker") + return { + f"worker-{index + 1}": index < session_workers + for index in range(session_workers + activity_workers) + } + + @dataclass class StepResult: step: int diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py index 34847cd8..4f46e1af 100644 --- a/activity_sessions/activities.py +++ b/activity_sessions/activities.py @@ -17,6 +17,7 @@ def __init__( @activity.defn async def conversation_step(self, step: int) -> StepResult: + # Only standalone activities lack a workflow Run ID; this sample calls it from a workflow. run_id = cast(str, activity.info().workflow_run_id) cache_hit = run_id in self.cache if not cache_hit: diff --git a/activity_sessions/display.py b/activity_sessions/display.py new file mode 100644 index 00000000..cdf56b2f --- /dev/null +++ b/activity_sessions/display.py @@ -0,0 +1,92 @@ +from collections.abc import Sequence +from dataclasses import dataclass + +from rich import box +from rich.console import Console +from rich.table import Table + +from activity_sessions import StepResult + + +@dataclass +class WorkflowResult: + workflow_id: str + steps: list[StepResult] + workflow_workers: set[str] + + +def print_worker_configuration(workers: dict[str, bool], console: Console) -> None: + table = Table(title="Worker configuration", box=box.ROUNDED) + table.add_column("Worker", style="cyan") + table.add_column("Role") + table.add_column("Session support") + for identity, session_enabled in workers.items(): + table.add_row( + identity, + "Workflow + activity" if session_enabled else "Activity only", + "[green]ON[/green]" if session_enabled else "[dim]OFF[/dim]", + ) + console.print(table) + + +def print_results( + affinity_enabled: bool, + runs: Sequence[WorkflowResult], + known_workers: dict[str, bool], + console: Console | None = None, +) -> None: + console = console or Console() + mode = "ON" if affinity_enabled else "OFF" + console.rule(f"Activity affinity {mode} — {len(runs)} workflow(s)") + table = Table(title="Workflow execution", box=box.SIMPLE) + for column in ( + "Workflow", + "Workflow worker", + "Step", + "Activity worker", + "Session support", + "Cache", + ): + table.add_column(column) + for run in runs: + for step in run.steps: + table.add_row( + run.workflow_id, + ", ".join(sorted(run.workflow_workers)), + str(step.step), + step.worker_identity, + "[green]ON[/green]" if step.session_enabled else "[dim]OFF[/dim]", + "[green]HIT[/green]" if step.cache_hit else "[yellow]MISS[/yellow]", + end_section=step.step == 3, + ) + console.print(table) + results = [step for run in runs for step in run.steps] + roles = { + **known_workers, + **{step.worker_identity: step.session_enabled for step in results}, + } + totals = Table(title="Per-worker totals for this batch", box=box.SIMPLE) + for column in ("Worker", "Activities", "Hits", "Misses / context loads"): + totals.add_column(column) + for identity in roles: + steps = [step for step in results if step.worker_identity == identity] + hits = sum(step.cache_hit for step in steps) + totals.add_row(identity, str(len(steps)), str(hits), str(len(steps) - hits)) + console.print(totals) + if affinity_enabled: + assert runs + for run in runs: + assert len(run.steps) == 3 + assert len(run.workflow_workers) == 1 + assert all( + step.worker_identity in run.workflow_workers for step in run.steps + ) + assert [step.cache_hit for step in run.steps] == [False, True, True] + console.print( + f"[green]Verified: {len(runs)} context load(s), then two cache hits " + "per workflow on its owner.[/green]" + ) + else: + console.print( + "[dim]Ordinary placement is not guaranteed; activities may also colocate by chance.[/dim]" + ) diff --git a/activity_sessions/pool.py b/activity_sessions/pool.py new file mode 100644 index 00000000..aca7a710 --- /dev/null +++ b/activity_sessions/pool.py @@ -0,0 +1,99 @@ +import argparse +import asyncio +import signal +import sys +from contextlib import suppress +from pathlib import Path + +from rich.console import Console + +from activity_sessions import TASK_QUEUE, worker_configuration +from activity_sessions.display import print_worker_configuration + + +async def run_pool( + workers: dict[str, bool], task_queue: str, context_file: Path +) -> None: + console = Console() + print_worker_configuration(workers, console) + processes: dict[str, asyncio.subprocess.Process] = {} + waiters: dict[asyncio.Task[int], str] = {} + try: + for identity, session_enabled in workers.items(): + args = [ + sys.executable, + "-m", + "activity_sessions.worker", + "--identity", + identity, + "--task-queue", + task_queue, + "--context-file", + str(context_file), + ] + if not session_enabled: + args.append("--activity-only") + processes[identity] = await asyncio.create_subprocess_exec( + *args, + # The pool forwards one interrupt, rather than children receiving Ctrl+C twice. + start_new_session=sys.platform != "win32", + ) + console.print( + f"[cyan]Launched {len(processes)} independent worker processes on {task_queue}. " + "Ctrl+C stops the whole pool.[/cyan]" + ) + waiters = { + asyncio.create_task(process.wait()): identity + for identity, process in processes.items() + } + done, _ = await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED) + exited = next(iter(done)) + raise RuntimeError( + f"{waiters[exited]} exited unexpectedly with code {exited.result()}" + ) + finally: + for process in processes.values(): + if process.returncode is None: + with suppress(ProcessLookupError): + if sys.platform == "win32": + process.terminate() + else: + process.send_signal(signal.SIGINT) + try: + await asyncio.wait_for( + asyncio.gather(*(process.wait() for process in processes.values())), 15 + ) + except asyncio.TimeoutError: + for process in processes.values(): + if process.returncode is None: + with suppress(ProcessLookupError): + process.kill() + await asyncio.gather(*(process.wait() for process in processes.values())) + await asyncio.gather(*waiters, return_exceptions=True) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Launch separate worker processes with private caches." + ) + parser.add_argument("--session-workers", type=int, default=2) + parser.add_argument("--activity-workers", type=int, default=0) + parser.add_argument("--task-queue", default=TASK_QUEUE) + parser.add_argument( + "--context-file", + type=Path, + default=Path(__file__).with_name("conversation.txt"), + ) + args = parser.parse_args() + try: + workers = worker_configuration(args.session_workers, args.activity_workers) + except ValueError as error: + parser.error(str(error)) + try: + asyncio.run(run_pool(workers, args.task_queue, args.context_file)) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/activity_sessions/starter.py b/activity_sessions/starter.py index 7329ea8f..2b966aa4 100644 --- a/activity_sessions/starter.py +++ b/activity_sessions/starter.py @@ -1,63 +1,34 @@ import argparse import asyncio import uuid -from collections.abc import Sequence from datetime import timedelta +from rich.console import Console from temporalio.client import Client from temporalio.envconfig import ClientConfig -from activity_sessions import TASK_QUEUE, StepResult +from activity_sessions import TASK_QUEUE, worker_configuration +from activity_sessions.display import ( + WorkflowResult, + print_results, + print_worker_configuration, +) from activity_sessions.workflows import ProcessConversation -def print_results( - affinity_enabled: bool, - results: Sequence[StepResult], - workflow_workers: set[str], - known_workers: dict[str, bool], -) -> None: - print(f"\nActivity affinity: {'ON' if affinity_enabled else 'OFF'}") - print(f"Workflow-task worker(s): {', '.join(sorted(workflow_workers))}") - print(f"{'Step':<6}{'Activity worker':<22}{'Session support':<18}Cache") - for result in results: - print( - f"{result.step:<6}{result.worker_identity:<22}" - f"{'ON' if result.session_enabled else 'OFF':<18}" - f"{'HIT' if result.cache_hit else 'MISS'}" - ) - print( - f"\n{'Worker':<22}{'Session support':<18}{'Steps':<8}{'Hits':<8}Misses / context loads" - ) - roles = { - **known_workers, - **{item.worker_identity: item.session_enabled for item in results}, - } - for identity, session_enabled in sorted(roles.items()): - steps = [item for item in results if item.worker_identity == identity] - hits = sum(item.cache_hit for item in steps) - print( - f"{identity:<22}{'ON' if session_enabled else 'OFF':<18}" - f"{len(steps):<8}{hits:<8}{len(steps) - hits}" - ) - if affinity_enabled: - assert len(results) == 3 - assert len(workflow_workers) == 1 - assert all(item.worker_identity in workflow_workers for item in results) - assert [item.cache_hit for item in results] == [False, True, True] - print("Verified: one context load, then two cache hits on the workflow worker.") - else: - print( - "Ordinary placement is not guaranteed; this run may also colocate by chance." - ) - - async def run_comparison( - mode: str, task_queue: str, known_workers: dict[str, bool] + mode: str, task_queue: str, known_workers: dict[str, bool], workflows: int = 1 ) -> None: + if workflows < 1: + raise ValueError("At least one workflow per mode is required") config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config) + console = Console() + print_worker_configuration(known_workers, console) + console.print( + "[dim]Configuration comes from the supplied counts, not a health check.[/dim]" + ) if mode == "compare": affinity_modes = [False, True] elif mode == "on": @@ -65,21 +36,31 @@ async def run_comparison( else: affinity_modes = [False] for affinity_enabled in affinity_modes: - handle = await client.start_workflow( - ProcessConversation.run, - affinity_enabled, - id=f"conversation-{uuid.uuid4()}", - task_queue=task_queue, - execution_timeout=timedelta(minutes=1), + + async def run_workflow(index: int) -> WorkflowResult: + workflow_id = ( + f"{'on' if affinity_enabled else 'off'}-{uuid.uuid4().hex[:8]}-{index}" + ) + handle = await client.start_workflow( + ProcessConversation.run, + affinity_enabled, + id=workflow_id, + task_queue=task_queue, + execution_timeout=timedelta(minutes=1), + ) + results = await handle.result() + history = await handle.fetch_history() + owners = { + event.workflow_task_started_event_attributes.identity + for event in history.events + if event.HasField("workflow_task_started_event_attributes") + } + return WorkflowResult(workflow_id, results, owners) + + runs = await asyncio.gather( + *(run_workflow(index + 1) for index in range(workflows)) ) - results = await handle.result() - history = await handle.fetch_history() - workflow_workers = { - event.workflow_task_started_event_attributes.identity - for event in history.events - if event.HasField("workflow_task_started_event_attributes") - } - print_results(affinity_enabled, results, workflow_workers, known_workers) + print_results(affinity_enabled, runs, known_workers, console) def main() -> None: @@ -88,16 +69,24 @@ def main() -> None: "--affinity", choices=["compare", "on", "off"], default="compare" ) parser.add_argument("--task-queue", default=TASK_QUEUE) + parser.add_argument("--session-workers", type=int, default=2) + parser.add_argument("--activity-workers", type=int, default=0) parser.add_argument( - "--session-workers", nargs="+", default=["worker-1", "worker-2"] + "--workflows", + type=int, + default=1, + help="Concurrent workflows per affinity mode", ) - parser.add_argument("--activity-workers", nargs="*", default=[]) args = parser.parse_args() - known_workers = { - **{identity: True for identity in args.session_workers}, - **{identity: False for identity in args.activity_workers}, - } - asyncio.run(run_comparison(args.affinity, args.task_queue, known_workers)) + if args.session_workers < 1: + parser.error("The demo needs at least one session worker to run workflows") + try: + workers = worker_configuration(args.session_workers, args.activity_workers) + asyncio.run( + run_comparison(args.affinity, args.task_queue, workers, args.workflows) + ) + except ValueError as error: + parser.error(str(error)) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index e44a4487..f685a2a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ Documentation = "https://docs.temporal.io/docs/python" "Bug Tracker" = "https://github.com/temporalio/samples-python/issues" [dependency-groups] +activity-sessions = ["rich>=13,<15"] dev = [ "ruff>=0.5.0,<0.6", "mypy>=1.4.1,<2", diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index 341972af..4b82af30 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -20,13 +20,16 @@ @pytest.mark.parametrize( - "activity_worker_count", [1, 8], ids=["three-workers", "ten-workers"] + "session_workers,activity_workers", + [(2, 1), (10, 0), (1, 9)], + ids=["three-workers", "ten-session-workers", "one-session-nine-activity-workers"], ) -async def test_comparison_with_independent_worker_processes( +async def test_pool_and_starter_cli( client: Client, request: pytest.FixtureRequest, tmp_path: Path, - activity_worker_count: int, + session_workers: int, + activity_workers: int, ) -> None: if request.config.getoption("--workflow-environment") in ("local", "time-skipping"): pytest.fail("Use --workflow-environment host:port for the custom server") @@ -38,99 +41,87 @@ async def test_comparison_with_independent_worker_processes( "TEMPORAL_ADDRESS": client.service_client.config.target_host, "TEMPORAL_NAMESPACE": client.namespace, } - processes = [] - logs = [] - worker_specs = [ - ("worker-1", False), - ("worker-2", False), - *[(f"worker-{index + 3}", True) for index in range(activity_worker_count)], + counts = [ + "--session-workers", + str(session_workers), + "--activity-workers", + str(activity_workers), ] - try: - for identity, activity_only in worker_specs: - log = (tmp_path / f"{identity}.log").open("w") - logs.append(log) - args = [ + processes = [] + with (tmp_path / "pool.log").open("w") as log: + try: + pool = await asyncio.create_subprocess_exec( sys.executable, "-m", - "activity_sessions.worker", - "--identity", - identity, + "activity_sessions.pool", + *counts, "--task-queue", task_queue, "--context-file", str(context_file), - ] - if activity_only: - args.append("--activity-only") - processes.append( - await asyncio.create_subprocess_exec( - *args, env=environment, stdout=log, stderr=asyncio.subprocess.STDOUT - ) + env=environment, + stdout=log, + stderr=asyncio.subprocess.STDOUT, ) + processes.append(pool) - async def workers_are_polling() -> None: - for process in processes: - assert process.returncode is None, "\n".join( - path.read_text() for path in tmp_path.glob("*.log") - ) - for kind, expected in [ - (TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW, {"worker-1", "worker-2"}), - ( - TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY, - {identity for identity, _ in worker_specs}, - ), - ]: - description = await client.workflow_service.describe_task_queue( - DescribeTaskQueueRequest( - namespace=client.namespace, - task_queue=TaskQueue(name=task_queue), - # Readiness must include pollers on every normal-queue partition. - api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, - task_queue_types=[kind], - versions=TaskQueueVersionSelection(unversioned=True), - report_pollers=True, + async def workers_are_polling() -> None: + assert pool.returncode is None, (tmp_path / "pool.log").read_text() + for kind, count in [ + (TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW, session_workers), + ( + TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY, + session_workers + activity_workers, + ), + ]: + description = await client.workflow_service.describe_task_queue( + DescribeTaskQueueRequest( + namespace=client.namespace, + task_queue=TaskQueue(name=task_queue), + api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, + task_queue_types=[kind], + versions=TaskQueueVersionSelection(unversioned=True), + report_pollers=True, + ) ) - ) - assert expected <= { - poller.identity - for version in description.versions_info.values() - for poller in version.types_info[kind].pollers - } + observed = { + poller.identity + for version in description.versions_info.values() + for poller in version.types_info[kind].pollers + } + assert {f"worker-{index + 1}" for index in range(count)} <= observed - await assert_eventually(workers_are_polling) - starter = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "activity_sessions.starter", - "--task-queue", - task_queue, - "--session-workers", - "worker-1", - "worker-2", - "--activity-workers", - *[identity for identity, activity_only in worker_specs if activity_only], - env=environment, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - processes.append(starter) - output, _ = await asyncio.wait_for(starter.communicate(), 60) - assert starter.returncode == 0, output.decode() - print(output.decode()) - finally: - for process in processes: - if process.returncode is None: - if sys.platform == "win32": - process.terminate() - else: - process.send_signal(signal.SIGINT) - for process in processes: - try: - await asyncio.wait_for(process.wait(), 15) - except asyncio.TimeoutError: - process.kill() - await process.wait() - for log in logs: - log.close() + await assert_eventually(workers_are_polling) + starter = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "activity_sessions.starter", + *counts, + "--task-queue", + task_queue, + "--workflows", + "2", + env=environment, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + processes.append(starter) + output, _ = await asyncio.wait_for(starter.communicate(), 60) + assert starter.returncode == 0, output.decode() + assert "Verified: 2 context load(s)" in output.decode() + print(output.decode()) + finally: + for process in processes: + if process.returncode is None: + if sys.platform == "win32": + process.terminate() + else: + process.send_signal(signal.SIGINT) + for process in processes: + try: + await asyncio.wait_for(process.wait(), 20) + except asyncio.TimeoutError: + process.kill() + await process.wait() if sys.platform != "win32": assert all(process.returncode == 0 for process in processes) diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py index 85570872..57fdb7b9 100644 --- a/tests/activity_sessions/report_test.py +++ b/tests/activity_sessions/report_test.py @@ -1,30 +1,82 @@ -from activity_sessions import StepResult -from activity_sessions.starter import print_results +import io +import pytest +from rich.console import Console -def test_report_includes_idle_worker_and_session_setting(capsys) -> None: +from activity_sessions import StepResult, worker_configuration +from activity_sessions.display import ( + WorkflowResult, + print_results, + print_worker_configuration, +) + + +def test_rich_report_separates_configuration_and_execution() -> None: + output = io.StringIO() + console = Console(file=output, width=140, color_system=None) + workers = worker_configuration(1, 1) + print_worker_configuration(workers, console) print_results( True, [ - StepResult(1, "worker-1", True, False), - StepResult(2, "worker-1", True, True), - StepResult(3, "worker-1", True, True), + WorkflowResult( + "workflow-1", + [StepResult(step, "worker-1", True, step != 1) for step in (1, 2, 3)], + {"worker-1"}, + ) ], - {"worker-1"}, - {"worker-1": True, "worker-2": False}, + workers, + console, + ) + text = output.getvalue() + assert "Worker configuration" in text + assert "Activity only" in text + assert "OFF" in text + assert "Workflow execution" in text + assert "workflow-1" in text + assert "Per-worker totals" in text + assert "MISS" in text and "HIT" in text + assert "Verified: 1 context load(s)" in text + + +def test_batch_reports_each_workflows_owner_and_its_cold_cache() -> None: + output = io.StringIO() + runs = [ + WorkflowResult( + f"workflow-{index}", + [ + StepResult(step, f"worker-{index}", True, step != 1) + for step in (1, 2, 3) + ], + {f"worker-{index}"}, + ) + for index in (1, 2) + ] + print_results( + True, runs, worker_configuration(2, 0), Console(file=output, width=140) ) - lines = capsys.readouterr().out.splitlines() - assert any(line.split() == ["1", "worker-1", "ON", "MISS"] for line in lines) - assert any(line.split() == ["worker-1", "ON", "3", "2", "1"] for line in lines) - assert any(line.split() == ["worker-2", "OFF", "0", "0", "0"] for line in lines) - assert any("Verified:" in line for line in lines) + assert "Verified: 2 context load(s)" in output.getvalue() -def test_ordinary_routing_may_colocate_by_chance(capsys) -> None: +def test_ordinary_routing_may_colocate_by_chance() -> None: + output = io.StringIO() print_results( False, - [StepResult(step, "worker-1", True, step != 1) for step in (1, 2, 3)], - {"worker-1"}, - {"worker-1": True}, + [ + WorkflowResult( + "workflow-1", [StepResult(1, "worker-1", True, False)], {"worker-1"} + ) + ], + worker_configuration(1, 0), + Console(file=output, width=140), ) - assert "may also colocate by chance" in capsys.readouterr().out + assert "may also colocate by chance" in output.getvalue() + + +def test_worker_counts_define_the_two_requested_pool_shapes() -> None: + assert list(worker_configuration(10, 0).values()) == [True] * 10 + assert list(worker_configuration(1, 9).values()) == [True] + [False] * 9 + with pytest.raises(ValueError): + worker_configuration(0, 0) + with pytest.raises(ValueError): + worker_configuration(-1, 2) diff --git a/uv.lock b/uv.lock index 8d0a3f3c..d7b02c13 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.15'", @@ -712,7 +712,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -4164,6 +4164,9 @@ dependencies = [ ] [package.dev-dependencies] +activity-sessions = [ + { name = "rich" }, +] bedrock = [ { name = "boto3" }, ] @@ -4258,6 +4261,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +activity-sessions = [{ name = "rich", specifier = ">=13,<15" }] bedrock = [{ name = "boto3", specifier = ">=1.34.92,<2" }] cloud-export-to-parquet = [ { name = "boto3", specifier = ">=1.34.89,<2" }, From 840ff4ab96c04d9222cf368d48284ed596ce4b9a Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 10:29:45 -0400 Subject: [PATCH 6/9] Run activity session comparison from one entrypoint --- activity_sessions/README.md | 48 +++----- activity_sessions/pool.py | 99 ---------------- activity_sessions/{starter.py => run.py} | 90 ++++++++++++--- activity_sessions/worker.py | 54 ++++----- tests/activity_sessions/integration_test.py | 119 +++++--------------- 5 files changed, 142 insertions(+), 268 deletions(-) delete mode 100644 activity_sessions/pool.py rename activity_sessions/{starter.py => run.py} (53%) diff --git a/activity_sessions/README.md b/activity_sessions/README.md index bb47b09a..64c9cc99 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -43,51 +43,35 @@ Rich is also declared in this repository's `activity-sessions` dependency group. The existing SDK environment/profile configuration selects the server and namespace, defaulting to `localhost:7233` and `default`. -## Launch a pool +## Run the demo -In one terminal, choose the worker counts: +One command starts the workers, runs the workflows, prints the comparison, and +shuts down the workers: ```bash # Ten session-capable workers, each able to run workflows and activities: -"$SDK_PYTHON" -m activity_sessions.pool --session-workers 10 +"$SDK_PYTHON" -m activity_sessions.run --session-workers 10 --workflows 10 # Alternatively, one session-capable worker and nine normal activity-only workers: -"$SDK_PYTHON" -m activity_sessions.pool --session-workers 1 --activity-workers 9 +"$SDK_PYTHON" -m activity_sessions.run \ + --session-workers 1 --activity-workers 9 --workflows 10 ``` -Run only one of these commands at a time. Each child is a fresh Python process +Each child is a fresh Python process with its own client, runtime, activity instance, and cache. Worker names are `worker-1` through `worker-N`; session workers come first. Ctrl+C stops the whole -pool, and a child exiting unexpectedly stops the pool rather than silently -leaving a smaller fleet. +demo. The launcher waits for all worker contexts to start before submitting +workflows. Startup and workflow execution have bounded timeouts. All workers use the same logical task queue. Session workers enable `WorkerSessionOptions()`; activity-only workers register no workflows. This prevents a workflow requiring session activities from landing on an unsupported worker. Eager activity execution is disabled. -The default pool is two session workers. A single process can still be started -manually: - -```bash -"$SDK_PYTHON" -m activity_sessions.worker --identity worker-1 -``` - -`--activity-only`, `--task-queue`, and `--context-file` are available on the worker. -The pool also accepts `--task-queue` and `--context-file`. - -## Run workflows - -In another terminal, use the same worker counts: - -```bash -# Match the ten-session-worker pool; run ten workflows concurrently per mode: -"$SDK_PYTHON" -m activity_sessions.starter --session-workers 10 --workflows 10 - -# Or match the one-session/nine-activity-worker pool: -"$SDK_PYTHON" -m activity_sessions.starter \ - --session-workers 1 --activity-workers 9 --workflows 10 -``` +Defaults are two session workers and one workflow per mode. `--context-file` +selects the mock external context. By default each invocation uses a unique task +queue so another demo's workers cannot affect placement; `--task-queue` overrides +it. The default `--affinity compare` runs the ordinary batch first, then the session batch, without changing worker configuration. **`--workflows 10` means ten per @@ -102,8 +86,8 @@ Rich renders: that worker's session support, and colored cache hit/miss. 3. Per-worker batch totals, including idle workers. -Configuration and idle rows come from the supplied counts, not a health check or -registry. Executing workers' capabilities come from activity results. +Configuration and idle rows describe the workers launched by this command. +Executing workers' capabilities come from activity results. With affinity on, each workflow should miss once and hit twice on its owner. Ordinary routing can scatter activities, or colocate them by chance; the sample @@ -134,7 +118,7 @@ isolation, failover, or physical queue provenance. Cache entries live for the worker process lifetime; production caches need resource limits and eviction. There are no per-worker queues, cache registry, or artifact protocol. -Unit tests do not require a server. Opt-in tests run the actual pool/starter CLIs +Unit tests do not require a server. Opt-in tests run the actual demo command with three workers, ten session workers, and one session/nine activity workers: ```bash diff --git a/activity_sessions/pool.py b/activity_sessions/pool.py deleted file mode 100644 index aca7a710..00000000 --- a/activity_sessions/pool.py +++ /dev/null @@ -1,99 +0,0 @@ -import argparse -import asyncio -import signal -import sys -from contextlib import suppress -from pathlib import Path - -from rich.console import Console - -from activity_sessions import TASK_QUEUE, worker_configuration -from activity_sessions.display import print_worker_configuration - - -async def run_pool( - workers: dict[str, bool], task_queue: str, context_file: Path -) -> None: - console = Console() - print_worker_configuration(workers, console) - processes: dict[str, asyncio.subprocess.Process] = {} - waiters: dict[asyncio.Task[int], str] = {} - try: - for identity, session_enabled in workers.items(): - args = [ - sys.executable, - "-m", - "activity_sessions.worker", - "--identity", - identity, - "--task-queue", - task_queue, - "--context-file", - str(context_file), - ] - if not session_enabled: - args.append("--activity-only") - processes[identity] = await asyncio.create_subprocess_exec( - *args, - # The pool forwards one interrupt, rather than children receiving Ctrl+C twice. - start_new_session=sys.platform != "win32", - ) - console.print( - f"[cyan]Launched {len(processes)} independent worker processes on {task_queue}. " - "Ctrl+C stops the whole pool.[/cyan]" - ) - waiters = { - asyncio.create_task(process.wait()): identity - for identity, process in processes.items() - } - done, _ = await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED) - exited = next(iter(done)) - raise RuntimeError( - f"{waiters[exited]} exited unexpectedly with code {exited.result()}" - ) - finally: - for process in processes.values(): - if process.returncode is None: - with suppress(ProcessLookupError): - if sys.platform == "win32": - process.terminate() - else: - process.send_signal(signal.SIGINT) - try: - await asyncio.wait_for( - asyncio.gather(*(process.wait() for process in processes.values())), 15 - ) - except asyncio.TimeoutError: - for process in processes.values(): - if process.returncode is None: - with suppress(ProcessLookupError): - process.kill() - await asyncio.gather(*(process.wait() for process in processes.values())) - await asyncio.gather(*waiters, return_exceptions=True) - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Launch separate worker processes with private caches." - ) - parser.add_argument("--session-workers", type=int, default=2) - parser.add_argument("--activity-workers", type=int, default=0) - parser.add_argument("--task-queue", default=TASK_QUEUE) - parser.add_argument( - "--context-file", - type=Path, - default=Path(__file__).with_name("conversation.txt"), - ) - args = parser.parse_args() - try: - workers = worker_configuration(args.session_workers, args.activity_workers) - except ValueError as error: - parser.error(str(error)) - try: - asyncio.run(run_pool(workers, args.task_queue, args.context_file)) - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/activity_sessions/starter.py b/activity_sessions/run.py similarity index 53% rename from activity_sessions/starter.py rename to activity_sessions/run.py index 2b966aa4..92b27937 100644 --- a/activity_sessions/starter.py +++ b/activity_sessions/run.py @@ -1,7 +1,9 @@ import argparse import asyncio +import multiprocessing import uuid from datetime import timedelta +from pathlib import Path from rich.console import Console from temporalio.client import Client @@ -13,22 +15,18 @@ print_results, print_worker_configuration, ) +from activity_sessions.worker import worker_process from activity_sessions.workflows import ProcessConversation async def run_comparison( - mode: str, task_queue: str, known_workers: dict[str, bool], workflows: int = 1 + client: Client, + mode: str, + task_queue: str, + known_workers: dict[str, bool], + workflows: int, ) -> None: - if workflows < 1: - raise ValueError("At least one workflow per mode is required") - config = ClientConfig.load_client_connect_config() - config.setdefault("target_host", "localhost:7233") - client = await Client.connect(**config) console = Console() - print_worker_configuration(known_workers, console) - console.print( - "[dim]Configuration comes from the supplied counts, not a health check.[/dim]" - ) if mode == "compare": affinity_modes = [False, True] elif mode == "on": @@ -63,12 +61,58 @@ async def run_workflow(index: int) -> WorkflowResult: print_results(affinity_enabled, runs, known_workers, console) +async def run_demo( + mode: str, + task_queue: str, + workers: dict[str, bool], + workflows: int, + context_file: Path, +) -> None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + print_worker_configuration(workers, Console()) + # Spawn gives each worker a fresh interpreter, SDK runtime, and private cache. + context = multiprocessing.get_context("spawn") + stop = context.Event() + processes = [] + ready_events = [] + try: + for identity, session_enabled in workers.items(): + ready = context.Event() + process = context.Process( + target=worker_process, + args=(identity, session_enabled, task_queue, context_file, ready, stop), + ) + process.start() + processes.append(process) + ready_events.append(ready) + started = await asyncio.gather( + *(asyncio.to_thread(ready.wait, 30) for ready in ready_events) + ) + if not all(started): + raise RuntimeError("A worker failed to start; see its output above") + await run_comparison(client, mode, task_queue, workers, workflows) + finally: + stop.set() + await asyncio.gather( + *(asyncio.to_thread(process.join, 15) for process in processes) + ) + for process in processes: + if process.is_alive(): + process.terminate() + await asyncio.to_thread(process.join) + process.close() + + def main() -> None: - parser = argparse.ArgumentParser() + parser = argparse.ArgumentParser( + description="Run a worker pool and compare activity affinity and cache reuse." + ) parser.add_argument( "--affinity", choices=["compare", "on", "off"], default="compare" ) - parser.add_argument("--task-queue", default=TASK_QUEUE) + parser.add_argument("--task-queue", default=f"{TASK_QUEUE}-{uuid.uuid4().hex[:8]}") parser.add_argument("--session-workers", type=int, default=2) parser.add_argument("--activity-workers", type=int, default=0) parser.add_argument( @@ -77,16 +121,32 @@ def main() -> None: default=1, help="Concurrent workflows per affinity mode", ) + parser.add_argument( + "--context-file", + type=Path, + default=Path(__file__).with_name("conversation.txt"), + ) args = parser.parse_args() if args.session_workers < 1: parser.error("The demo needs at least one session worker to run workflows") + if args.workflows < 1: + parser.error("At least one workflow per mode is required") try: workers = worker_configuration(args.session_workers, args.activity_workers) - asyncio.run( - run_comparison(args.affinity, args.task_queue, workers, args.workflows) - ) except ValueError as error: parser.error(str(error)) + try: + asyncio.run( + run_demo( + args.affinity, + args.task_queue, + workers, + args.workflows, + args.context_file, + ) + ) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index 68b91fa0..eeca8346 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -1,24 +1,28 @@ -import argparse import asyncio import os +import signal +from multiprocessing.synchronize import Event from pathlib import Path from temporalio.client import Client from temporalio.envconfig import ClientConfig from temporalio.worker import Worker, WorkerSessionOptions -from activity_sessions import TASK_QUEUE from activity_sessions.activities import ConversationActivities from activity_sessions.workflows import ProcessConversation async def run_worker( - identity: str, activity_only: bool, task_queue: str, context_file: Path + identity: str, + session_enabled: bool, + task_queue: str, + context_file: Path, + ready: Event, + stop: Event, ) -> None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config, identity=identity) - session_enabled = not activity_only activities = ConversationActivities(identity, session_enabled, context_file) worker = Worker( client, @@ -35,29 +39,21 @@ async def run_worker( f"session_support={'ON' if session_enabled else 'OFF'}", flush=True, ) - await worker.run() - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--identity", required=True) - parser.add_argument("--activity-only", action="store_true") - parser.add_argument("--task-queue", default=TASK_QUEUE) - parser.add_argument( - "--context-file", - type=Path, - default=Path(__file__).with_name("conversation.txt"), + async with worker: + ready.set() + await asyncio.to_thread(stop.wait) + + +def worker_process( + identity: str, + session_enabled: bool, + task_queue: str, + context_file: Path, + ready: Event, + stop: Event, +) -> None: + # The parent handles Ctrl+C and releases all workers through the shared event. + signal.signal(signal.SIGINT, signal.SIG_IGN) + asyncio.run( + run_worker(identity, session_enabled, task_queue, context_file, ready, stop) ) - args = parser.parse_args() - try: - asyncio.run( - run_worker( - args.identity, args.activity_only, args.task_queue, args.context_file - ) - ) - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index 4b82af30..1279b935 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -2,17 +2,11 @@ import os import signal import sys -import uuid from pathlib import Path import pytest -from temporalio.api.enums.v1 import DescribeTaskQueueMode, TaskQueueType -from temporalio.api.taskqueue.v1 import TaskQueue, TaskQueueVersionSelection -from temporalio.api.workflowservice.v1 import DescribeTaskQueueRequest from temporalio.client import Client -from tests.helpers import assert_eventually - pytestmark = pytest.mark.skipif( os.getenv("TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE") != "1", reason="requires the session SDK and custom server from temporal#12630", @@ -24,7 +18,7 @@ [(2, 1), (10, 0), (1, 9)], ids=["three-workers", "ten-session-workers", "one-session-nine-activity-workers"], ) -async def test_pool_and_starter_cli( +async def test_demo_cli( client: Client, request: pytest.FixtureRequest, tmp_path: Path, @@ -33,95 +27,34 @@ async def test_pool_and_starter_cli( ) -> None: if request.config.getoption("--workflow-environment") in ("local", "time-skipping"): pytest.fail("Use --workflow-environment host:port for the custom server") - task_queue = f"session-cache-{uuid.uuid4()}" context_file = tmp_path / "conversation.txt" context_file.write_text("Conversation context") - environment = { - **os.environ, - "TEMPORAL_ADDRESS": client.service_client.config.target_host, - "TEMPORAL_NAMESPACE": client.namespace, - } - counts = [ + demo = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "activity_sessions.run", "--session-workers", str(session_workers), "--activity-workers", str(activity_workers), - ] - processes = [] - with (tmp_path / "pool.log").open("w") as log: - try: - pool = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "activity_sessions.pool", - *counts, - "--task-queue", - task_queue, - "--context-file", - str(context_file), - env=environment, - stdout=log, - stderr=asyncio.subprocess.STDOUT, - ) - processes.append(pool) - - async def workers_are_polling() -> None: - assert pool.returncode is None, (tmp_path / "pool.log").read_text() - for kind, count in [ - (TaskQueueType.TASK_QUEUE_TYPE_WORKFLOW, session_workers), - ( - TaskQueueType.TASK_QUEUE_TYPE_ACTIVITY, - session_workers + activity_workers, - ), - ]: - description = await client.workflow_service.describe_task_queue( - DescribeTaskQueueRequest( - namespace=client.namespace, - task_queue=TaskQueue(name=task_queue), - api_mode=DescribeTaskQueueMode.DESCRIBE_TASK_QUEUE_MODE_ENHANCED, - task_queue_types=[kind], - versions=TaskQueueVersionSelection(unversioned=True), - report_pollers=True, - ) - ) - observed = { - poller.identity - for version in description.versions_info.values() - for poller in version.types_info[kind].pollers - } - assert {f"worker-{index + 1}" for index in range(count)} <= observed - - await assert_eventually(workers_are_polling) - starter = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "activity_sessions.starter", - *counts, - "--task-queue", - task_queue, - "--workflows", - "2", - env=environment, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - processes.append(starter) - output, _ = await asyncio.wait_for(starter.communicate(), 60) - assert starter.returncode == 0, output.decode() - assert "Verified: 2 context load(s)" in output.decode() - print(output.decode()) - finally: - for process in processes: - if process.returncode is None: - if sys.platform == "win32": - process.terminate() - else: - process.send_signal(signal.SIGINT) - for process in processes: - try: - await asyncio.wait_for(process.wait(), 20) - except asyncio.TimeoutError: - process.kill() - await process.wait() - if sys.platform != "win32": - assert all(process.returncode == 0 for process in processes) + "--workflows", + "2", + "--context-file", + str(context_file), + env={ + **os.environ, + "TEMPORAL_ADDRESS": client.service_client.config.target_host, + "TEMPORAL_NAMESPACE": client.namespace, + }, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + try: + output, _ = await asyncio.wait_for(demo.communicate(), 120) + assert demo.returncode == 0, output.decode() + assert "Verified: 2 context load(s)" in output.decode() + print(output.decode()) + finally: + if demo.returncode is None: + demo.send_signal(signal.SIGINT) + await asyncio.wait_for(demo.wait(), 25) From 9c875580ec76fd3ba66dce870b6780a63b3b66c6 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 11:01:29 -0400 Subject: [PATCH 7/9] Simplify sample lifecycle and run instructions --- activity_sessions/README.md | 152 ++++++++++++------------------------ activity_sessions/run.py | 151 ++++++++++++++++------------------- activity_sessions/worker.py | 7 +- 3 files changed, 115 insertions(+), 195 deletions(-) diff --git a/activity_sessions/README.md b/activity_sessions/README.md index 64c9cc99..5a949a68 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -1,131 +1,75 @@ # Activity sessions and a worker-local cache -This experimental sample needs the session SDK from -[sdk-python#1979](https://github.com/temporalio/sdk-python/pull/1979) and the server -from [temporal#12630](https://github.com/temporalio/temporal/pull/12630), with -`system.enableStickyActivityExecution` enabled for your namespace. +This sample shows how activity affinity lets a workflow reuse conversation context +cached in its worker's memory, without managing per-worker task queues. -The workflow remains simply: +Each workflow calls three regular activities sequentially: ```text conversation_step(1) → conversation_step(2) → conversation_step(3) ``` -Each worker process owns one activity instance and dictionary cache, keyed by -workflow Run ID. A miss reads `conversation.txt`, a stand-in for an external -conversation source. Further activities on the same process reuse that context. -The dictionary and context are not workflow state. A new execution starts cold, -and a new process can reload the context from the file. +Each worker runs in a separate process with its own dictionary cache, keyed by +workflow Run ID. A cache miss loads `conversation.txt`, standing in for an external +conversation source. The context is **worker-local, not workflow state**, and can +be reloaded if absent. -Only the activity option changes between the two runs: +With affinity **on**, activities use +`workflow.ActivitySessionOptions.workflow_session()`: expect one MISS followed by +two HITs on the workflow's worker. With affinity **off**, activities may execute +on different workers and load the context separately. They may also colocate by +chance; ordinary routing does not guarantee either outcome. -```python -"session_options": ( - workflow.ActivitySessionOptions.workflow_session() - if affinity_enabled - else None -) -``` +## Prerequisites + +1. Run the server from [temporal#12630](https://github.com/temporalio/temporal/pull/12630), + with the `default` namespace and `system.enableStickyActivityExecution` enabled. +2. Build the SDK from [sdk-python#1979](https://github.com/temporalio/sdk-python/pull/1979) + by running `poe build-develop` in that SDK checkout. +3. From the **samples repository root**, select the built SDK's interpreter and + install Rich for the result tables: + + ```bash + export SDK_PYTHON=/absolute/path/to/sdk-python/.venv/bin/python + uv pip install --python "$SDK_PYTHON" 'rich>=13,<15' + ``` -## Setup +Use this interpreter for the commands below; the released SDK does not provide +the experimental session API. The sample connects to `localhost:7233`, namespace +`default`, unless overridden by `TEMPORAL_ADDRESS` / `TEMPORAL_NAMESPACE` or SDK +profile configuration. -Build the session SDK with `poe build-develop`. From the samples repository root, -set `SDK_PYTHON` in each terminal to that SDK checkout's `.venv/bin/python`, then -install the sample's Rich display dependency into that environment: +## Run + +From the **samples repository root**, run: ```bash -export SDK_PYTHON=/absolute/path/to/sdk-python/.venv/bin/python -uv pip install --python "$SDK_PYTHON" 'rich>=13,<15' +"$SDK_PYTHON" -m activity_sessions.run ``` -Use this interpreter rather than allowing `uv run` to select the released SDK. -Rich is also declared in this repository's `activity-sessions` dependency group. -The existing SDK environment/profile configuration selects the server and -namespace, defaulting to `localhost:7233` and `default`. - -## Run the demo +This starts two session-capable worker processes, runs one workflow with affinity +off and another with affinity on, prints the results, and shuts down the workers. +Ctrl+C also requests shutdown of all workers. -One command starts the workers, runs the workflows, prints the comparison, and -shuts down the workers: +For a larger pool, choose either: ```bash -# Ten session-capable workers, each able to run workflows and activities: +# Ten session-capable workers; ten workflows per affinity mode. "$SDK_PYTHON" -m activity_sessions.run --session-workers 10 --workflows 10 -# Alternatively, one session-capable worker and nine normal activity-only workers: +# One session-capable worker plus nine ordinary activity-only workers. "$SDK_PYTHON" -m activity_sessions.run \ --session-workers 1 --activity-workers 9 --workflows 10 ``` -Each child is a fresh Python process -with its own client, runtime, activity instance, and cache. Worker names are -`worker-1` through `worker-N`; session workers come first. Ctrl+C stops the whole -demo. The launcher waits for all worker contexts to start before submitting -workflows. Startup and workflow execution have bounded timeouts. - -All workers use the same logical task queue. Session workers enable -`WorkerSessionOptions()`; activity-only workers register no workflows. This -prevents a workflow requiring session activities from landing on an unsupported -worker. Eager activity execution is disabled. - -Defaults are two session workers and one workflow per mode. `--context-file` -selects the mock external context. By default each invocation uses a unique task -queue so another demo's workers cannot affect placement; `--task-queue` overrides -it. - -The default `--affinity compare` runs the ordinary batch first, then the session -batch, without changing worker configuration. **`--workflows 10` means ten per -mode: twenty executions total in comparison mode.** `--affinity on` or `off` -runs only that mode. Every workflow still executes exactly three activities -sequentially. - -Rich renders: - -1. A worker-configuration table with roles and session support. -2. An execution table showing workflow ID, workflow owner, step, activity worker, - that worker's session support, and colored cache hit/miss. -3. Per-worker batch totals, including idle workers. - -Configuration and idle rows describe the workers launched by this command. -Executing workers' capabilities come from activity results. - -With affinity on, each workflow should miss once and hit twice on its owner. -Ordinary routing can scatter activities, or colocate them by chance; the sample -reports the actual result rather than forcing alternation. +`--workflows` is the concurrent workflow count **per mode**: the commands above +run twenty workflows total. Use `--affinity on` or `--affinity off` to run just one +mode. Workers stay unchanged between the two batches, and eager activity +execution is disabled. Only session-capable workers register workflows. -## Observe in Temporal UI - -A standalone `temporal-server` does not start a UI. Its `7243` port is the HTTP -API, not the UI. For a local server on macOS with Docker Desktop, a separate UI -can be started with: - -```bash -docker run --rm --name temporal-session-ui \ - -p 127.0.0.1:8080:8080 \ - -e TEMPORAL_ADDRESS=host.docker.internal:7233 \ - temporalio/ui:latest -``` - -Open , select your namespace, and find the workflow IDs -shown in the terminal. This container connects to the custom server; it does not -launch a stock server. Linux Docker host connectivity may require different -network configuration. - -## Scope and validation - -This demonstrates healthy-worker affinity and cache reuse, not lifetime -isolation, failover, or physical queue provenance. Cache entries live for the -worker process lifetime; production caches need resource limits and eviction. -There are no per-worker queues, cache registry, or artifact protocol. - -Unit tests do not require a server. Opt-in tests run the actual demo command -with three workers, ten session workers, and one session/nine activity workers: - -```bash -"$SDK_PYTHON" -m pytest tests/activity_sessions -q -TEMPORAL_TEST_ACTIVITY_SESSION_SAMPLE=1 "$SDK_PYTHON" -m pytest \ - tests/activity_sessions -q -s --workflow-environment localhost:7233 -``` +The tables show worker configuration, which worker executed each workflow/activity, +cache HITs/MISSes, and per-worker totals including idle workers. -The integration tests use the existing custom server; they do not start or -reconfigure it. +Each invocation uses a unique shared task queue to avoid interference from other +runs. Optional `--task-queue` and `--context-file` arguments override the defaults. +Run with `--help` to see all options. diff --git a/activity_sessions/run.py b/activity_sessions/run.py index 92b27937..13c91a3b 100644 --- a/activity_sessions/run.py +++ b/activity_sessions/run.py @@ -19,93 +19,40 @@ from activity_sessions.workflows import ProcessConversation -async def run_comparison( +async def run_workflows( client: Client, - mode: str, + affinity_enabled: bool, task_queue: str, known_workers: dict[str, bool], workflows: int, ) -> None: - console = Console() - if mode == "compare": - affinity_modes = [False, True] - elif mode == "on": - affinity_modes = [True] - else: - affinity_modes = [False] - for affinity_enabled in affinity_modes: - - async def run_workflow(index: int) -> WorkflowResult: - workflow_id = ( - f"{'on' if affinity_enabled else 'off'}-{uuid.uuid4().hex[:8]}-{index}" - ) - handle = await client.start_workflow( - ProcessConversation.run, - affinity_enabled, - id=workflow_id, - task_queue=task_queue, - execution_timeout=timedelta(minutes=1), - ) - results = await handle.result() - history = await handle.fetch_history() - owners = { - event.workflow_task_started_event_attributes.identity - for event in history.events - if event.HasField("workflow_task_started_event_attributes") - } - return WorkflowResult(workflow_id, results, owners) - - runs = await asyncio.gather( - *(run_workflow(index + 1) for index in range(workflows)) - ) - print_results(affinity_enabled, runs, known_workers, console) - - -async def run_demo( - mode: str, - task_queue: str, - workers: dict[str, bool], - workflows: int, - context_file: Path, -) -> None: - config = ClientConfig.load_client_connect_config() - config.setdefault("target_host", "localhost:7233") - client = await Client.connect(**config) - print_worker_configuration(workers, Console()) - # Spawn gives each worker a fresh interpreter, SDK runtime, and private cache. - context = multiprocessing.get_context("spawn") - stop = context.Event() - processes = [] - ready_events = [] - try: - for identity, session_enabled in workers.items(): - ready = context.Event() - process = context.Process( - target=worker_process, - args=(identity, session_enabled, task_queue, context_file, ready, stop), - ) - process.start() - processes.append(process) - ready_events.append(ready) - started = await asyncio.gather( - *(asyncio.to_thread(ready.wait, 30) for ready in ready_events) + async def run_workflow(index: int) -> WorkflowResult: + workflow_id = ( + f"{'on' if affinity_enabled else 'off'}-{uuid.uuid4().hex[:8]}-{index}" ) - if not all(started): - raise RuntimeError("A worker failed to start; see its output above") - await run_comparison(client, mode, task_queue, workers, workflows) - finally: - stop.set() - await asyncio.gather( - *(asyncio.to_thread(process.join, 15) for process in processes) + handle = await client.start_workflow( + ProcessConversation.run, + affinity_enabled, + id=workflow_id, + task_queue=task_queue, + execution_timeout=timedelta(minutes=1), ) - for process in processes: - if process.is_alive(): - process.terminate() - await asyncio.to_thread(process.join) - process.close() + results = await handle.result() + history = await handle.fetch_history() + owners = { + event.workflow_task_started_event_attributes.identity + for event in history.events + if event.HasField("workflow_task_started_event_attributes") + } + return WorkflowResult(workflow_id, results, owners) + + runs = await asyncio.gather( + *(run_workflow(index + 1) for index in range(workflows)) + ) + print_results(affinity_enabled, runs, known_workers) -def main() -> None: +async def main() -> None: parser = argparse.ArgumentParser( description="Run a worker pool and compare activity affinity and cache reuse." ) @@ -135,19 +82,53 @@ def main() -> None: workers = worker_configuration(args.session_workers, args.activity_workers) except ValueError as error: parser.error(str(error)) + + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + print_worker_configuration(workers, Console()) + # Spawn gives each worker a fresh interpreter, SDK runtime, and private cache. + context = multiprocessing.get_context("spawn") + stop = context.Event() + processes = [] try: - asyncio.run( - run_demo( - args.affinity, + for identity, session_enabled in workers.items(): + process = context.Process( + target=worker_process, + args=( + identity, + session_enabled, + args.task_queue, + args.context_file, + stop, + ), + ) + process.start() + processes.append(process) + + if args.affinity == "compare": + affinity_modes = [False, True] + else: + affinity_modes = [args.affinity == "on"] + for affinity_enabled in affinity_modes: + await run_workflows( + client, + affinity_enabled, args.task_queue, workers, args.workflows, - args.context_file, ) + finally: + stop.set() + await asyncio.gather( + *(asyncio.to_thread(process.join) for process in processes) ) - except KeyboardInterrupt: - pass + for process in processes: + process.close() if __name__ == "__main__": - main() + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index eeca8346..45f49535 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -17,7 +17,6 @@ async def run_worker( session_enabled: bool, task_queue: str, context_file: Path, - ready: Event, stop: Event, ) -> None: config = ClientConfig.load_client_connect_config() @@ -40,7 +39,6 @@ async def run_worker( flush=True, ) async with worker: - ready.set() await asyncio.to_thread(stop.wait) @@ -49,11 +47,8 @@ def worker_process( session_enabled: bool, task_queue: str, context_file: Path, - ready: Event, stop: Event, ) -> None: # The parent handles Ctrl+C and releases all workers through the shared event. signal.signal(signal.SIGINT, signal.SIG_IGN) - asyncio.run( - run_worker(identity, session_enabled, task_queue, context_file, ready, stop) - ) + asyncio.run(run_worker(identity, session_enabled, task_queue, context_file, stop)) From 6b204286498cf39c278a94a3a391c4312fddfcdb Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 13:29:14 -0400 Subject: [PATCH 8/9] Make activity session cache impact visible Simulate a slow context load on cache misses, scale the default run to 4 workers x 20 workflows x 5 steps, and stream activity events to the parent for an optional live view and a side-by-side summary with an impact table (context loads, cache copies, latency). Co-Authored-By: Claude Opus 5.5 --- activity_sessions/README.md | 65 +++-- activity_sessions/__init__.py | 24 +- activity_sessions/activities.py | 37 +-- activity_sessions/conversation.txt | 2 - activity_sessions/display.py | 249 ++++++++++++++------ activity_sessions/live.py | 86 +++++++ activity_sessions/run.py | 195 +++++++++------ activity_sessions/worker.py | 36 +-- activity_sessions/workflows.py | 39 ++- pyproject.toml | 3 - tests/activity_sessions/cache_test.py | 70 +++--- tests/activity_sessions/integration_test.py | 13 +- tests/activity_sessions/report_test.py | 113 ++++----- 13 files changed, 574 insertions(+), 358 deletions(-) delete mode 100644 activity_sessions/conversation.txt create mode 100644 activity_sessions/live.py diff --git a/activity_sessions/README.md b/activity_sessions/README.md index 5a949a68..f6d24a65 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -3,21 +3,17 @@ This sample shows how activity affinity lets a workflow reuse conversation context cached in its worker's memory, without managing per-worker task queues. -Each workflow calls three regular activities sequentially: - -```text -conversation_step(1) → conversation_step(2) → conversation_step(3) -``` - -Each worker runs in a separate process with its own dictionary cache, keyed by -workflow Run ID. A cache miss loads `conversation.txt`, standing in for an external -conversation source. The context is **worker-local, not workflow state**, and can -be reloaded if absent. +Each workflow calls `conversation_step` sequentially (five times by default). Each +worker runs in a separate process with its own dictionary cache, keyed by workflow +Run ID. A cache miss sleeps for `--load-seconds` (default 1s), standing in for a +slow fetch of conversation history; a hit is instant. The context is +**worker-local, not workflow state**, and can be reloaded if absent. With affinity **on**, activities use -`workflow.ActivitySessionOptions.workflow_session()`: expect one MISS followed by -two HITs on the workflow's worker. With affinity **off**, activities may execute -on different workers and load the context separately. They may also colocate by +`workflow.ActivitySessionOptions.workflow_session()`: each workflow loads its +context once and every later step hits the cache on the same worker. With affinity +**off**, steps spread across the pool, so the same context is loaded on several +workers and workflows take several times longer. Steps may still colocate by chance; ordinary routing does not guarantee either outcome. ## Prerequisites @@ -47,29 +43,32 @@ From the **samples repository root**, run: "$SDK_PYTHON" -m activity_sessions.run ``` -This starts two session-capable worker processes, runs one workflow with affinity -off and another with affinity on, prints the results, and shuts down the workers. -Ctrl+C also requests shutdown of all workers. +This starts four session-capable worker processes, runs 20 concurrent workflows +with affinity off and then 20 with affinity on, and shuts down the workers. -For a larger pool, choose either: +While a batch runs, a live view shows each worker's cache filling up and a +placement strip per workflow: `●` is a context load (miss), `○` a cache hit, `◌` +a load in progress, and the number/color is the worker that ran the step. The +final summary prints both batches side by side, followed by an impact table: +context loads, cache copies per workflow, time spent loading, and workflow latency. +Pass `--no-live` (or redirect output) to print only the summary. -```bash -# Ten session-capable workers; ten workflows per affinity mode. -"$SDK_PYTHON" -m activity_sessions.run --session-workers 10 --workflows 10 +Useful options (run with `--help` for all): -# One session-capable worker plus nine ordinary activity-only workers. -"$SDK_PYTHON" -m activity_sessions.run \ - --session-workers 1 --activity-workers 9 --workflows 10 -``` +```bash +# More workers and steps make ordinary placement scatter even more. +"$SDK_PYTHON" -m activity_sessions.run --session-workers 8 --steps 10 -`--workflows` is the concurrent workflow count **per mode**: the commands above -run twenty workflows total. Use `--affinity on` or `--affinity off` to run just one -mode. Workers stay unchanged between the two batches, and eager activity -execution is disabled. Only session-capable workers register workflows. +# One session-capable worker plus three activity-only workers. +"$SDK_PYTHON" -m activity_sessions.run --session-workers 1 --activity-workers 3 -The tables show worker configuration, which worker executed each workflow/activity, -cache HITs/MISSes, and per-worker totals including idle workers. +# Run only one mode, with a faster simulated load. +"$SDK_PYTHON" -m activity_sessions.run --affinity on --load-seconds 0.2 +``` -Each invocation uses a unique shared task queue to avoid interference from other -runs. Optional `--task-queue` and `--context-file` arguments override the defaults. -Run with `--help` to see all options. +`--workflows` is the concurrent workflow count **per mode**. Only session-capable +workers register the workflow; activity-only workers never receive session +activities. Workflows start only after every worker is polling, eager activity +execution is disabled, and each invocation uses a unique task queue. The command +exits non-zero if any affinity-on workflow did not load once and reuse its cache on +a single worker. diff --git a/activity_sessions/__init__.py b/activity_sessions/__init__.py index 96a41883..ad53e297 100644 --- a/activity_sessions/__init__.py +++ b/activity_sessions/__init__.py @@ -3,24 +3,12 @@ TASK_QUEUE = "activity-session-cache" -def worker_configuration( - session_workers: int, activity_workers: int -) -> dict[str, bool]: - if ( - session_workers < 0 - or activity_workers < 0 - or session_workers + activity_workers == 0 - ): - raise ValueError("Worker counts must be nonnegative, with at least one worker") - return { - f"worker-{index + 1}": index < session_workers - for index in range(session_workers + activity_workers) - } +@dataclass(frozen=True) +class StepEvent: + """Sent by an activity when a step starts and again when it finishes.""" - -@dataclass -class StepResult: + workflow_id: str step: int - worker_identity: str - session_enabled: bool + worker: str cache_hit: bool + finished: bool diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py index 4f46e1af..bd3d17f6 100644 --- a/activity_sessions/activities.py +++ b/activity_sessions/activities.py @@ -1,31 +1,36 @@ -from pathlib import Path +import asyncio +from collections.abc import Callable from typing import cast from temporalio import activity -from activity_sessions import StepResult +from activity_sessions import StepEvent class ConversationActivities: + """Activities sharing one worker-local cache; each worker process has its own.""" + def __init__( - self, worker_identity: str, session_enabled: bool, context_file: Path + self, + worker_identity: str, + load_seconds: float, + emit: Callable[[StepEvent], None], ) -> None: self.worker_identity = worker_identity - self.session_enabled = session_enabled - self.context_file = context_file + self.load_seconds = load_seconds + self.emit = emit self.cache: dict[str, str] = {} @activity.defn - async def conversation_step(self, step: int) -> StepResult: - # Only standalone activities lack a workflow Run ID; this sample calls it from a workflow. - run_id = cast(str, activity.info().workflow_run_id) + async def conversation_step(self, step: int) -> None: + info = activity.info() + # Only standalone activities lack workflow IDs; this sample calls it from a workflow. + workflow_id = cast(str, info.workflow_id) + run_id = cast(str, info.workflow_run_id) cache_hit = run_id in self.cache + self.emit(StepEvent(workflow_id, step, self.worker_identity, cache_hit, False)) if not cache_hit: - self.cache[run_id] = self.context_file.read_text() - context = self.cache[run_id] - print( - f"[{self.worker_identity}] step={step} " - f"cache={'HIT' if cache_hit else 'MISS'} context_size={len(context)}", - flush=True, - ) - return StepResult(step, self.worker_identity, self.session_enabled, cache_hit) + # Stands in for a slow fetch of conversation history from an external store. + await asyncio.sleep(self.load_seconds) + self.cache[run_id] = f"Conversation context for {workflow_id}" + self.emit(StepEvent(workflow_id, step, self.worker_identity, cache_hit, True)) diff --git a/activity_sessions/conversation.txt b/activity_sessions/conversation.txt deleted file mode 100644 index a66b6da2..00000000 --- a/activity_sessions/conversation.txt +++ /dev/null @@ -1,2 +0,0 @@ -Customer: I am planning a weekend trip and need help choosing a destination. -Agent context: The customer prefers train travel, museums, and vegetarian food. diff --git a/activity_sessions/display.py b/activity_sessions/display.py index cdf56b2f..4199ba05 100644 --- a/activity_sessions/display.py +++ b/activity_sessions/display.py @@ -1,92 +1,201 @@ -from collections.abc import Sequence -from dataclasses import dataclass +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field from rich import box -from rich.console import Console +from rich.console import Console, Group, RenderableType from rich.table import Table +from rich.text import Text -from activity_sessions import StepResult +from activity_sessions import StepEvent + +WORKER_COLORS = [ + "cyan", + "magenta", + "green", + "yellow", + "blue", + "red", + "bright_cyan", + "bright_magenta", + "bright_green", + "bright_yellow", +] +LEGEND = "● context load (miss) ○ cache hit ◌ loading number/color = worker" @dataclass -class WorkflowResult: - workflow_id: str - steps: list[StepResult] - workflow_workers: set[str] +class Batch: + affinity_enabled: bool + workflow_ids: list[str] + steps: dict[str, dict[int, StepEvent]] = field(default_factory=dict) + durations: dict[str, float] = field(default_factory=dict) + wall_seconds: float = 0.0 + + @property + def mode(self) -> str: + return f"Affinity {'ON' if self.affinity_enabled else 'OFF'}" + + def record(self, event: StepEvent) -> None: + if event.workflow_id not in self.workflow_ids: + raise ValueError(f"Event for unknown workflow {event.workflow_id}") + self.steps.setdefault(event.workflow_id, {})[event.step] = event + + def finished_steps(self) -> list[StepEvent]: + return [ + event + for steps in self.steps.values() + for event in steps.values() + if event.finished + ] + + +def worker_number(worker: str) -> int: + return int(worker.rsplit("-", 1)[1]) + +def worker_color(worker: str) -> str: + return WORKER_COLORS[(worker_number(worker) - 1) % len(WORKER_COLORS)] -def print_worker_configuration(workers: dict[str, bool], console: Console) -> None: + +def placement_strip( + batch: Batch, steps: int, worker_count: int, show_pending: bool = False +) -> Table: + """One row per workflow and one cell per step: glyph = cache result, color = worker.""" + width = len(str(worker_count)) + 1 + table = Table(box=None, show_header=False, padding=(0, 1, 0, 0)) + table.add_column(style="dim") + table.add_column() + table.add_column(justify="right") + for index, workflow_id in enumerate(batch.workflow_ids, 1): + events = batch.steps.get(workflow_id, {}) + if not events and not show_pending: + continue + cells = Text() + for step in range(1, steps + 1): + event = events.get(step) + if event is None: + cells.append("·".ljust(width) + " ", style="dim") + continue + if event.cache_hit: + glyph = "○" + else: + glyph = "●" if event.finished else "◌" + cell = f"{glyph}{worker_number(event.worker)}".ljust(width) + " " + cells.append(cell, style=worker_color(event.worker)) + duration = batch.durations.get(workflow_id) + table.add_row( + f"wf-{index:02}", cells, f"{duration:.1f}s" if duration is not None else "" + ) + return table + + +def print_worker_configuration(workers: Mapping[str, bool], console: Console) -> None: table = Table(title="Worker configuration", box=box.ROUNDED) - table.add_column("Worker", style="cyan") + table.add_column("Worker") table.add_column("Role") table.add_column("Session support") for identity, session_enabled in workers.items(): table.add_row( - identity, + Text(identity, style=worker_color(identity)), "Workflow + activity" if session_enabled else "Activity only", "[green]ON[/green]" if session_enabled else "[dim]OFF[/dim]", ) console.print(table) -def print_results( - affinity_enabled: bool, - runs: Sequence[WorkflowResult], - known_workers: dict[str, bool], - console: Console | None = None, -) -> None: - console = console or Console() - mode = "ON" if affinity_enabled else "OFF" - console.rule(f"Activity affinity {mode} — {len(runs)} workflow(s)") - table = Table(title="Workflow execution", box=box.SIMPLE) - for column in ( - "Workflow", - "Workflow worker", - "Step", - "Activity worker", - "Session support", - "Cache", - ): - table.add_column(column) - for run in runs: - for step in run.steps: - table.add_row( - run.workflow_id, - ", ".join(sorted(run.workflow_workers)), - str(step.step), - step.worker_identity, - "[green]ON[/green]" if step.session_enabled else "[dim]OFF[/dim]", - "[green]HIT[/green]" if step.cache_hit else "[yellow]MISS[/yellow]", - end_section=step.step == 3, +def percentile(values: Sequence[float], fraction: float) -> float: + ordered = sorted(values) + return ordered[max(0, math.ceil(fraction * len(ordered)) - 1)] + + +def affinity_violations(batch: Batch) -> list[str]: + """Workflows that did not load once and then reuse the cache on a single worker.""" + violations = [] + for workflow_id in batch.workflow_ids: + events = sorted(batch.steps.get(workflow_id, {}).values(), key=lambda e: e.step) + workers = {event.worker for event in events} + hits = [event.cache_hit for event in events] + if len(workers) != 1 or hits != [False] + [True] * (len(hits) - 1): + violations.append(workflow_id) + return violations + + +def comparison_table(batches: Sequence[Batch], load_seconds: float) -> Table: + table = Table(title="Impact", box=box.SIMPLE_HEAD) + table.add_column("") + for batch in batches: + table.add_column(batch.mode, justify="right") + compare = len(batches) == 2 + if compare: + table.add_column("Change", justify="right", style="bold green") + + def add( + label: str, values: list[float], fmt: str, change: str | None = None + ) -> None: + row = [label, *(format(value, fmt) for value in values)] + if compare: + row.append(change or "") + table.add_row(*row) + + loads = [ + float(sum(not event.cache_hit for event in batch.finished_steps())) + for batch in batches + ] + copies = [load / len(batch.workflow_ids) for load, batch in zip(loads, batches)] + p50 = [percentile(list(batch.durations.values()), 0.5) for batch in batches] + p95 = [percentile(list(batch.durations.values()), 0.95) for batch in batches] + walls = [batch.wall_seconds for batch in batches] + + def fewer(values: list[float]) -> str | None: + if not compare or not values[0]: + return None + return f"{1 - values[1] / values[0]:.0%} fewer" + + def faster(values: list[float]) -> str | None: + if not compare or not values[1]: + return None + return f"{values[0] / values[1]:.1f}x faster" + + add("Context loads", loads, ".0f", fewer(loads)) + add("Cache copies per workflow", copies, ".1f") + add("Time spent loading (s)", [load * load_seconds for load in loads], ".1f") + add("Workflow latency p50 (s)", p50, ".1f", faster(p50)) + add("Workflow latency p95 (s)", p95, ".1f", faster(p95)) + add("Batch wall time (s)", walls, ".1f", faster(walls)) + return table + + +def summary( + batches: Sequence[Batch], steps: int, worker_count: int, load_seconds: float +) -> RenderableType: + strips = Table.grid(padding=(0, 4)) + for batch in batches: + strips.add_column() + strips.add_row(*(Text(batch.mode, style="bold") for batch in batches)) + strips.add_row(*(placement_strip(batch, steps, worker_count) for batch in batches)) + parts: list[RenderableType] = [ + strips, + Text(LEGEND, style="dim"), + comparison_table(batches, load_seconds), + ] + for batch in batches: + if not batch.affinity_enabled: + continue + violations = affinity_violations(batch) + if violations: + parts.append( + Text( + f"Affinity not observed for: {', '.join(violations)}", + style="bold red", + ) ) - console.print(table) - results = [step for run in runs for step in run.steps] - roles = { - **known_workers, - **{step.worker_identity: step.session_enabled for step in results}, - } - totals = Table(title="Per-worker totals for this batch", box=box.SIMPLE) - for column in ("Worker", "Activities", "Hits", "Misses / context loads"): - totals.add_column(column) - for identity in roles: - steps = [step for step in results if step.worker_identity == identity] - hits = sum(step.cache_hit for step in steps) - totals.add_row(identity, str(len(steps)), str(hits), str(len(steps) - hits)) - console.print(totals) - if affinity_enabled: - assert runs - for run in runs: - assert len(run.steps) == 3 - assert len(run.workflow_workers) == 1 - assert all( - step.worker_identity in run.workflow_workers for step in run.steps + else: + parts.append( + Text( + f"Verified: each of {len(batch.workflow_ids)} workflow(s) loaded " + "its context once and reused it on a single worker.", + style="green", + ) ) - assert [step.cache_hit for step in run.steps] == [False, True, True] - console.print( - f"[green]Verified: {len(runs)} context load(s), then two cache hits " - "per workflow on its owner.[/green]" - ) - else: - console.print( - "[dim]Ordinary placement is not guaranteed; activities may also colocate by chance.[/dim]" - ) + return Group(*parts) diff --git a/activity_sessions/live.py b/activity_sessions/live.py new file mode 100644 index 00000000..37a74ca9 --- /dev/null +++ b/activity_sessions/live.py @@ -0,0 +1,86 @@ +"""Optional live terminal view; the demo runs the same without it (--no-live).""" + +from collections.abc import Mapping +from types import TracebackType + +from rich.columns import Columns +from rich.console import Console, Group, RenderableType +from rich.live import Live +from rich.panel import Panel +from rich.rule import Rule +from rich.text import Text + +from activity_sessions.display import ( + LEGEND, + Batch, + placement_strip, + worker_color, +) + + +class LiveView: + def __init__(self, workers: Mapping[str, bool], steps: int, console: Console): + self.workers = workers + self.steps = steps + self.live = Live(console=console, auto_refresh=False, transient=True) + + def __enter__(self) -> "LiveView": + self.live.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.live.stop() + + def show(self, batch: Batch) -> None: + self.live.update(self.render(batch), refresh=True) + + def render(self, batch: Batch) -> RenderableType: + labels = {workflow_id: i for i, workflow_id in enumerate(batch.workflow_ids, 1)} + events = [event for steps in batch.steps.values() for event in steps.values()] + panels = [] + for worker, session_enabled in self.workers.items(): + mine = [event for event in events if event.worker == worker] + cached = sorted( + labels[event.workflow_id] + for event in mine + if not event.cache_hit and event.finished + ) + loading = sum(not event.cache_hit and not event.finished for event in mine) + hits = sum(event.cache_hit for event in mine) + body = Text() + body.append(f"loads {len(cached) + loading} hits {hits}\n") + body.append("◌ " * loading + "\n", style="yellow") + body.append("cache: ", style="dim") + body.append(" ".join(f"{label:02}" for label in cached)) + title = worker if session_enabled else f"{worker} (activity only)" + panels.append( + Panel( + body, + title=Text(title, style=f"bold {worker_color(worker)}"), + width=29, + ) + ) + entries = sum(not event.cache_hit for event in events) + started = len(batch.steps) + fleet = Text( + f"Fleet: {entries} cache entries for {started} started workflow(s)" + + (f" ({entries / started:.1f} copies each)" if started else ""), + style="bold", + ) + return Group( + Rule( + Text( + f"{batch.mode} - {len(batch.workflow_ids)} workflow(s)", + style="bold", + ) + ), + Columns(panels), + fleet, + placement_strip(batch, self.steps, len(self.workers), show_pending=True), + Text(LEGEND, style="dim"), + ) diff --git a/activity_sessions/run.py b/activity_sessions/run.py index 13c91a3b..f5d920fa 100644 --- a/activity_sessions/run.py +++ b/activity_sessions/run.py @@ -1,55 +1,76 @@ import argparse import asyncio +import contextlib import multiprocessing +import queue +import time import uuid from datetime import timedelta -from pathlib import Path +from multiprocessing.queues import Queue +from typing import TYPE_CHECKING from rich.console import Console from temporalio.client import Client from temporalio.envconfig import ClientConfig -from activity_sessions import TASK_QUEUE, worker_configuration +from activity_sessions import TASK_QUEUE, StepEvent from activity_sessions.display import ( - WorkflowResult, - print_results, + Batch, + affinity_violations, print_worker_configuration, + summary, ) from activity_sessions.worker import worker_process -from activity_sessions.workflows import ProcessConversation +from activity_sessions.workflows import ConversationInput, ProcessConversation +if TYPE_CHECKING: + from activity_sessions.live import LiveView -async def run_workflows( + +async def run_batch( client: Client, + args: argparse.Namespace, affinity_enabled: bool, - task_queue: str, - known_workers: dict[str, bool], - workflows: int, -) -> None: - async def run_workflow(index: int) -> WorkflowResult: - workflow_id = ( - f"{'on' if affinity_enabled else 'off'}-{uuid.uuid4().hex[:8]}-{index}" - ) - handle = await client.start_workflow( + events: "Queue[StepEvent]", + view: "LiveView | None", +) -> Batch: + prefix = f"{'on' if affinity_enabled else 'off'}-{uuid.uuid4().hex[:6]}" + batch = Batch( + affinity_enabled, + [f"{prefix}-{index:02}" for index in range(1, args.workflows + 1)], + ) + started = time.monotonic() + + async def run_workflow(workflow_id: str) -> None: + begin = time.monotonic() + await client.execute_workflow( ProcessConversation.run, - affinity_enabled, + ConversationInput(affinity_enabled, args.steps), id=workflow_id, - task_queue=task_queue, - execution_timeout=timedelta(minutes=1), + task_queue=args.task_queue, + execution_timeout=timedelta(minutes=5), ) - results = await handle.result() - history = await handle.fetch_history() - owners = { - event.workflow_task_started_event_attributes.identity - for event in history.events - if event.HasField("workflow_task_started_event_attributes") - } - return WorkflowResult(workflow_id, results, owners) + batch.durations[workflow_id] = time.monotonic() - begin - runs = await asyncio.gather( - *(run_workflow(index + 1) for index in range(workflows)) - ) - print_results(affinity_enabled, runs, known_workers) + pending = {asyncio.create_task(run_workflow(w)) for w in batch.workflow_ids} + expected_steps = args.workflows * args.steps + # Workflows can complete just before their activities' last events arrive. + while pending or len(batch.finished_steps()) < expected_steps: + if pending: + done, pending = await asyncio.wait(pending, timeout=0.1) + for task in done: + task.result() + else: + await asyncio.sleep(0.05) + while True: + try: + batch.record(events.get_nowait()) + except queue.Empty: + break + if view: + view.show(batch) + batch.wall_seconds = time.monotonic() - started + return batch async def main() -> None: @@ -60,71 +81,93 @@ async def main() -> None: "--affinity", choices=["compare", "on", "off"], default="compare" ) parser.add_argument("--task-queue", default=f"{TASK_QUEUE}-{uuid.uuid4().hex[:8]}") - parser.add_argument("--session-workers", type=int, default=2) - parser.add_argument("--activity-workers", type=int, default=0) + parser.add_argument("--session-workers", type=int, default=4) parser.add_argument( - "--workflows", + "--activity-workers", type=int, - default=1, - help="Concurrent workflows per affinity mode", + default=0, + help="Additional activity-only workers without session support", + ) + parser.add_argument( + "--workflows", type=int, default=20, help="Concurrent workflows per mode" + ) + parser.add_argument( + "--steps", type=int, default=5, help="Sequential activities per workflow" + ) + parser.add_argument( + "--load-seconds", + type=float, + default=1.0, + help="Simulated latency of loading context on a cache miss", ) parser.add_argument( - "--context-file", - type=Path, - default=Path(__file__).with_name("conversation.txt"), + "--no-live", action="store_true", help="Only print the final summary" ) args = parser.parse_args() if args.session_workers < 1: parser.error("The demo needs at least one session worker to run workflows") - if args.workflows < 1: - parser.error("At least one workflow per mode is required") - try: - workers = worker_configuration(args.session_workers, args.activity_workers) - except ValueError as error: - parser.error(str(error)) + if args.activity_workers < 0: + parser.error("--activity-workers cannot be negative") + if args.workflows < 1 or args.steps < 1: + parser.error("--workflows and --steps must be at least 1") + workers = { + f"worker-{index}": index <= args.session_workers + for index in range(1, args.session_workers + args.activity_workers + 1) + } config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config) - print_worker_configuration(workers, Console()) + console = Console() + print_worker_configuration(workers, console) + # Spawn gives each worker a fresh interpreter, SDK runtime, and private cache. context = multiprocessing.get_context("spawn") - stop = context.Event() - processes = [] + events: "Queue[StepEvent]" = context.Queue() + ready = context.Barrier(len(workers) + 1) + processes = [ + context.Process( + target=worker_process, + args=( + identity, + session_enabled, + args.task_queue, + args.load_seconds, + events, + ready, + ), + daemon=True, + ) + for identity, session_enabled in workers.items() + ] + modes = [False, True] if args.affinity == "compare" else [args.affinity == "on"] + batches = [] try: - for identity, session_enabled in workers.items(): - process = context.Process( - target=worker_process, - args=( - identity, - session_enabled, - args.task_queue, - args.context_file, - stop, - ), - ) + for process in processes: process.start() - processes.append(process) + with console.status("Starting workers..."): + # Start workflows only once every worker polls, so none gets a head start. + await asyncio.to_thread(ready.wait, 60) + view = None + if not args.no_live and console.is_terminal: + from activity_sessions.live import LiveView - if args.affinity == "compare": - affinity_modes = [False, True] - else: - affinity_modes = [args.affinity == "on"] - for affinity_enabled in affinity_modes: - await run_workflows( - client, - affinity_enabled, - args.task_queue, - workers, - args.workflows, - ) + view = LiveView(workers, args.steps, console) + with view or contextlib.nullcontext(): + for affinity_enabled in modes: + batches.append( + await run_batch(client, args, affinity_enabled, events, view) + ) finally: - stop.set() - await asyncio.gather( - *(asyncio.to_thread(process.join) for process in processes) - ) + ready.abort() for process in processes: - process.close() + process.terminate() + for process in processes: + process.join() + + console.print(summary(batches, args.steps, len(workers), args.load_seconds)) + if any(affinity_violations(batch) for batch in batches if batch.affinity_enabled): + raise SystemExit(1) if __name__ == "__main__": diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index 45f49535..e2c9e35d 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -1,13 +1,13 @@ import asyncio -import os import signal -from multiprocessing.synchronize import Event -from pathlib import Path +from multiprocessing.queues import Queue +from multiprocessing.synchronize import Barrier from temporalio.client import Client from temporalio.envconfig import ClientConfig from temporalio.worker import Worker, WorkerSessionOptions +from activity_sessions import StepEvent from activity_sessions.activities import ConversationActivities from activity_sessions.workflows import ProcessConversation @@ -16,13 +16,14 @@ async def run_worker( identity: str, session_enabled: bool, task_queue: str, - context_file: Path, - stop: Event, + load_seconds: float, + events: "Queue[StepEvent]", + ready: Barrier, ) -> None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config, identity=identity) - activities = ConversationActivities(identity, session_enabled, context_file) + activities = ConversationActivities(identity, load_seconds, events.put) worker = Worker( client, task_queue=task_queue, @@ -30,25 +31,24 @@ async def run_worker( activities=[activities.conversation_step], session_worker_options=WorkerSessionOptions() if session_enabled else None, disable_eager_activity_execution=True, - max_cached_workflows=100 if session_enabled else 0, - max_concurrent_activities=2, - ) - print( - f"{identity} pid={os.getpid()} queue={task_queue} " - f"session_support={'ON' if session_enabled else 'OFF'}", - flush=True, + max_concurrent_activities=100, ) async with worker: - await asyncio.to_thread(stop.wait) + await asyncio.to_thread(ready.wait) + # Run until the parent terminates this process. + await asyncio.Event().wait() def worker_process( identity: str, session_enabled: bool, task_queue: str, - context_file: Path, - stop: Event, + load_seconds: float, + events: "Queue[StepEvent]", + ready: Barrier, ) -> None: - # The parent handles Ctrl+C and releases all workers through the shared event. + # The parent handles Ctrl+C and terminates every worker. signal.signal(signal.SIGINT, signal.SIG_IGN) - asyncio.run(run_worker(identity, session_enabled, task_queue, context_file, stop)) + asyncio.run( + run_worker(identity, session_enabled, task_queue, load_seconds, events, ready) + ) diff --git a/activity_sessions/workflows.py b/activity_sessions/workflows.py index 3d745d6b..e81cd4de 100644 --- a/activity_sessions/workflows.py +++ b/activity_sessions/workflows.py @@ -1,31 +1,30 @@ +from dataclasses import dataclass from datetime import timedelta from temporalio import workflow from temporalio.common import RetryPolicy -from activity_sessions import StepResult + +@dataclass +class ConversationInput: + affinity_enabled: bool + steps: int @workflow.defn class ProcessConversation: @workflow.run - async def run(self, affinity_enabled: bool) -> list[StepResult]: - options: workflow.ActivityConfig = { - "start_to_close_timeout": timedelta(seconds=30), - "retry_policy": RetryPolicy(maximum_attempts=1), - "session_options": ( - workflow.ActivitySessionOptions.workflow_session() - if affinity_enabled - else None - ), - } - first = await workflow.execute_activity( - "conversation_step", 1, result_type=StepResult, **options - ) - second = await workflow.execute_activity( - "conversation_step", 2, result_type=StepResult, **options - ) - third = await workflow.execute_activity( - "conversation_step", 3, result_type=StepResult, **options + async def run(self, input: ConversationInput) -> None: + session_options = ( + workflow.ActivitySessionOptions.workflow_session() + if input.affinity_enabled + else None ) - return [first, second, third] + for step in range(1, input.steps + 1): + await workflow.execute_activity( + "conversation_step", + step, + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RetryPolicy(maximum_attempts=1), + session_options=session_options, + ) diff --git a/pyproject.toml b/pyproject.toml index 48e92e68..93e386d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,9 +112,6 @@ constraint-dependencies = [ [tool.setuptools.packages.find] exclude = ["lambda_worker*"] -[tool.setuptools.package-data] -activity_sessions = ["conversation.txt"] - [build-system] requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" diff --git a/tests/activity_sessions/cache_test.py b/tests/activity_sessions/cache_test.py index 73b0a8f6..17fc13bd 100644 --- a/tests/activity_sessions/cache_test.py +++ b/tests/activity_sessions/cache_test.py @@ -1,46 +1,48 @@ from dataclasses import replace -from pathlib import Path -from unittest.mock import patch from temporalio.testing import ActivityEnvironment +from activity_sessions import StepEvent from activity_sessions.activities import ConversationActivities -async def test_same_worker_loads_context_once() -> None: - activities = ConversationActivities("worker-1", True, Path("context.txt")) - env = ActivityEnvironment() - env.info = replace(env.info, workflow_run_id="run-1") - with patch.object(Path, "read_text", return_value="Conversation context") as read: - first = await env.run(activities.conversation_step, 1) - second = await env.run(activities.conversation_step, 2) - third = await env.run(activities.conversation_step, 3) - assert [first.cache_hit, second.cache_hit, third.cache_hit] == [False, True, True] - read.assert_called_once() - - -async def test_other_worker_has_its_own_cache(tmp_path: Path) -> None: - context_file = tmp_path / "context.txt" - context_file.write_text("Conversation context") - first_worker = ConversationActivities("worker-1", True, context_file) - other_worker = ConversationActivities("worker-2", False, context_file) +def environment(run_id: str) -> ActivityEnvironment: env = ActivityEnvironment() - env.info = replace(env.info, workflow_run_id="run-1") - first = await env.run(first_worker.conversation_step, 1) - second = await env.run(other_worker.conversation_step, 2) - third = await env.run(first_worker.conversation_step, 3) - assert [first.cache_hit, second.cache_hit, third.cache_hit] == [False, False, True] + env.info = replace(env.info, workflow_id="workflow-1", workflow_run_id=run_id) + return env + + +def finished_hits(events: list[StepEvent]) -> list[bool]: + return [event.cache_hit for event in events if event.finished] + + +async def test_same_worker_loads_context_once() -> None: + events: list[StepEvent] = [] + activities = ConversationActivities("worker-1", 0, events.append) + env = environment("run-1") + for step in (1, 2, 3): + await env.run(activities.conversation_step, step) + assert finished_hits(events) == [False, True, True] + assert events[0] == StepEvent("workflow-1", 1, "worker-1", False, False) + assert list(activities.cache) == ["run-1"] + + +async def test_other_worker_has_its_own_cache() -> None: + events: list[StepEvent] = [] + first_worker = ConversationActivities("worker-1", 0, events.append) + other_worker = ConversationActivities("worker-2", 0, events.append) + env = environment("run-1") + await env.run(first_worker.conversation_step, 1) + await env.run(other_worker.conversation_step, 2) + await env.run(first_worker.conversation_step, 3) + assert finished_hits(events) == [False, False, True] assert first_worker.cache is not other_worker.cache - assert other_worker.cache["run-1"] == "Conversation context" + assert "run-1" in other_worker.cache async def test_new_run_starts_cold_on_same_worker() -> None: - activities = ConversationActivities("worker-1", True, Path("context.txt")) - env = ActivityEnvironment() - with patch.object(Path, "read_text", return_value="Conversation context") as read: - env.info = replace(env.info, workflow_run_id="run-1") - await env.run(activities.conversation_step, 1) - env.info = replace(env.info, workflow_run_id="run-2") - result = await env.run(activities.conversation_step, 1) - assert not result.cache_hit - assert read.call_count == 2 + events: list[StepEvent] = [] + activities = ConversationActivities("worker-1", 0, events.append) + await environment("run-1").run(activities.conversation_step, 1) + await environment("run-2").run(activities.conversation_step, 1) + assert finished_hits(events) == [False, False] diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index 1279b935..c1d5ca7a 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -2,7 +2,6 @@ import os import signal import sys -from pathlib import Path import pytest from temporalio.client import Client @@ -21,14 +20,11 @@ async def test_demo_cli( client: Client, request: pytest.FixtureRequest, - tmp_path: Path, session_workers: int, activity_workers: int, ) -> None: if request.config.getoption("--workflow-environment") in ("local", "time-skipping"): pytest.fail("Use --workflow-environment host:port for the custom server") - context_file = tmp_path / "conversation.txt" - context_file.write_text("Conversation context") demo = await asyncio.create_subprocess_exec( sys.executable, "-m", @@ -39,8 +35,11 @@ async def test_demo_cli( str(activity_workers), "--workflows", "2", - "--context-file", - str(context_file), + "--steps", + "3", + "--load-seconds", + "0.2", + "--no-live", env={ **os.environ, "TEMPORAL_ADDRESS": client.service_client.config.target_host, @@ -52,7 +51,7 @@ async def test_demo_cli( try: output, _ = await asyncio.wait_for(demo.communicate(), 120) assert demo.returncode == 0, output.decode() - assert "Verified: 2 context load(s)" in output.decode() + assert "Verified: each of 2 workflow(s)" in output.decode() print(output.decode()) finally: if demo.returncode is None: diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py index 57fdb7b9..3a860033 100644 --- a/tests/activity_sessions/report_test.py +++ b/tests/activity_sessions/report_test.py @@ -3,80 +3,71 @@ import pytest from rich.console import Console -from activity_sessions import StepResult, worker_configuration +from activity_sessions import StepEvent from activity_sessions.display import ( - WorkflowResult, - print_results, + Batch, + affinity_violations, print_worker_configuration, + summary, ) -def test_rich_report_separates_configuration_and_execution() -> None: +def batch(affinity_enabled: bool, placements: dict[str, list[tuple[str, bool]]]): + result = Batch(affinity_enabled, list(placements)) + for workflow_id, steps in placements.items(): + for step, (worker, cache_hit) in enumerate(steps, 1): + result.record(StepEvent(workflow_id, step, worker, cache_hit, True)) + result.durations[workflow_id] = sum(not hit for _, hit in steps) * 1.0 + result.wall_seconds = max(result.durations.values()) + return result + + +def render(*batches: Batch) -> str: output = io.StringIO() - console = Console(file=output, width=140, color_system=None) - workers = worker_configuration(1, 1) - print_worker_configuration(workers, console) - print_results( - True, - [ - WorkflowResult( - "workflow-1", - [StepResult(step, "worker-1", True, step != 1) for step in (1, 2, 3)], - {"worker-1"}, - ) - ], - workers, - console, + Console(file=output, width=140, color_system=None).print( + summary(batches, 3, 2, 1.0) ) - text = output.getvalue() - assert "Worker configuration" in text - assert "Activity only" in text - assert "OFF" in text - assert "Workflow execution" in text - assert "workflow-1" in text - assert "Per-worker totals" in text - assert "MISS" in text and "HIT" in text - assert "Verified: 1 context load(s)" in text + return output.getvalue() -def test_batch_reports_each_workflows_owner_and_its_cold_cache() -> None: - output = io.StringIO() - runs = [ - WorkflowResult( - f"workflow-{index}", - [ - StepResult(step, f"worker-{index}", True, step != 1) - for step in (1, 2, 3) - ], - {f"worker-{index}"}, - ) - for index in (1, 2) - ] - print_results( - True, runs, worker_configuration(2, 0), Console(file=output, width=140) +def test_comparison_reports_loads_and_speedup() -> None: + off = batch( + False, {"off-1": [("worker-1", False), ("worker-2", False), ("worker-1", True)]} + ) + on = batch( + True, {"on-1": [("worker-2", False), ("worker-2", True), ("worker-2", True)]} ) - assert "Verified: 2 context load(s)" in output.getvalue() + text = render(off, on) + assert "Affinity OFF" in text and "Affinity ON" in text + assert "●1" in text and "○2" in text + assert "50% fewer" in text + assert "2.0x faster" in text + assert "Verified: each of 1 workflow(s)" in text -def test_ordinary_routing_may_colocate_by_chance() -> None: - output = io.StringIO() - print_results( - False, - [ - WorkflowResult( - "workflow-1", [StepResult(1, "worker-1", True, False)], {"worker-1"} - ) - ], - worker_configuration(1, 0), - Console(file=output, width=140), +def test_affinity_violations_flag_split_or_repeated_loads() -> None: + result = batch( + True, + { + "good": [("worker-1", False), ("worker-1", True), ("worker-1", True)], + "split": [("worker-1", False), ("worker-2", False), ("worker-1", True)], + }, ) - assert "may also colocate by chance" in output.getvalue() + assert affinity_violations(result) == ["split"] + assert "Affinity not observed for: split" in render(result) -def test_worker_counts_define_the_two_requested_pool_shapes() -> None: - assert list(worker_configuration(10, 0).values()) == [True] * 10 - assert list(worker_configuration(1, 9).values()) == [True] + [False] * 9 - with pytest.raises(ValueError): - worker_configuration(0, 0) +def test_unknown_workflow_event_is_rejected() -> None: + result = Batch(True, ["known"]) with pytest.raises(ValueError): - worker_configuration(-1, 2) + result.record(StepEvent("other", 1, "worker-1", False, True)) + + +def test_worker_configuration_lists_roles() -> None: + output = io.StringIO() + print_worker_configuration( + {"worker-1": True, "worker-2": False}, + Console(file=output, width=140, color_system=None), + ) + assert "Workflow + activity" in output.getvalue() + assert "Activity only" in output.getvalue() From 821bcbf23e268b6eecb864ebb6e445ec121c8770 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 22 Oct 2026 17:29:53 -0400 Subject: [PATCH 9/9] Model the activity session cache as conversation turns Each step now needs every earlier conversation turn. A worker fetches only the turns it is missing, with simulated latency per turn, so stale workers pay proportionally and the owner under affinity never fetches. The strip, live view, impact table, and verification report turns fetched instead of whole-context cache hits. Co-Authored-By: Claude Opus 5.5 --- activity_sessions/README.md | 47 +++++----- activity_sessions/__init__.py | 3 +- activity_sessions/activities.py | 28 +++--- activity_sessions/display.py | 95 ++++++++++++++------- activity_sessions/live.py | 36 +++++--- activity_sessions/run.py | 12 +-- activity_sessions/worker.py | 8 +- activity_sessions/workflows.py | 2 +- tests/activity_sessions/cache_test.py | 56 +++++++----- tests/activity_sessions/integration_test.py | 4 +- tests/activity_sessions/report_test.py | 47 +++++----- 11 files changed, 208 insertions(+), 130 deletions(-) diff --git a/activity_sessions/README.md b/activity_sessions/README.md index f6d24a65..9fef4430 100644 --- a/activity_sessions/README.md +++ b/activity_sessions/README.md @@ -1,19 +1,23 @@ # Activity sessions and a worker-local cache -This sample shows how activity affinity lets a workflow reuse conversation context +This sample shows how activity affinity lets a workflow reuse conversation history cached in its worker's memory, without managing per-worker task queues. -Each workflow calls `conversation_step` sequentially (five times by default). Each -worker runs in a separate process with its own dictionary cache, keyed by workflow -Run ID. A cache miss sleeps for `--load-seconds` (default 1s), standing in for a -slow fetch of conversation history; a hit is instant. The context is -**worker-local, not workflow state**, and can be reloaded if absent. +Each workflow is a conversation: it calls `conversation_step(0)`, +`conversation_step(1)`, ... sequentially (six turns by default). Turn `n` needs +every earlier turn `0..n-1`, then produces turn `n`. Each worker runs in a +separate process with its own dictionary cache of the turns it holds, keyed by +workflow Run ID. Before running a turn, a worker fetches only the earlier turns it +is missing, sleeping `--fetch-seconds` (default 0.5s) per turn to stand in for an +external conversation store. Turn 0 needs no history. The history is +**worker-local, not workflow state**, and can be refetched if absent. With affinity **on**, activities use -`workflow.ActivitySessionOptions.workflow_session()`: each workflow loads its -context once and every later step hits the cache on the same worker. With affinity -**off**, steps spread across the pool, so the same context is loaded on several -workers and workflows take several times longer. Steps may still colocate by +`workflow.ActivitySessionOptions.workflow_session()`: the workflow's worker +produced every earlier turn itself, so it never fetches. With affinity **off**, +turns spread across the pool. A worker that ran turn 0 and then returns for turn 2 +must fetch turn 1, and a cold worker at turn `n` fetches all `n` earlier turns, so +fetch time grows with how stale each worker is. Turns may still colocate by chance; ordinary routing does not guarantee either outcome. ## Prerequisites @@ -46,29 +50,32 @@ From the **samples repository root**, run: This starts four session-capable worker processes, runs 20 concurrent workflows with affinity off and then 20 with affinity on, and shuts down the workers. -While a batch runs, a live view shows each worker's cache filling up and a -placement strip per workflow: `●` is a context load (miss), `○` a cache hit, `◌` -a load in progress, and the number/color is the worker that ran the step. The -final summary prints both batches side by side, followed by an impact table: -context loads, cache copies per workflow, time spent loading, and workflow latency. +While a batch runs, a live view shows how far each worker holds every +conversation (`03:t4` means workflow 3 through turn 4) and a placement strip per +workflow. In the strip, `○2` means worker 2 ran the turn with every earlier turn +already cached, `●2(3)` means worker 2 first fetched 3 missing turns, and `◌` is +a fetch in progress; color also identifies the worker. The final summary prints +both batches side by side, followed by an impact table: turns fetched, steps that +had to fetch, time spent fetching, cached turns across the fleet, and workflow +latency. Pass `--no-live` (or redirect output) to print only the summary. Useful options (run with `--help` for all): ```bash -# More workers and steps make ordinary placement scatter even more. +# More workers and longer conversations make ordinary placement fetch even more. "$SDK_PYTHON" -m activity_sessions.run --session-workers 8 --steps 10 # One session-capable worker plus three activity-only workers. "$SDK_PYTHON" -m activity_sessions.run --session-workers 1 --activity-workers 3 -# Run only one mode, with a faster simulated load. -"$SDK_PYTHON" -m activity_sessions.run --affinity on --load-seconds 0.2 +# Run only one mode, with a faster simulated fetch. +"$SDK_PYTHON" -m activity_sessions.run --affinity off --fetch-seconds 0.1 ``` `--workflows` is the concurrent workflow count **per mode**. Only session-capable workers register the workflow; activity-only workers never receive session activities. Workflows start only after every worker is polling, eager activity execution is disabled, and each invocation uses a unique task queue. The command -exits non-zero if any affinity-on workflow did not load once and reuse its cache on -a single worker. +exits non-zero if any affinity-on workflow ran on more than one worker or fetched +any turn. diff --git a/activity_sessions/__init__.py b/activity_sessions/__init__.py index ad53e297..7981b4cb 100644 --- a/activity_sessions/__init__.py +++ b/activity_sessions/__init__.py @@ -10,5 +10,6 @@ class StepEvent: workflow_id: str step: int worker: str - cache_hit: bool + # Earlier conversation turns this worker had to fetch before running the step. + fetched: int finished: bool diff --git a/activity_sessions/activities.py b/activity_sessions/activities.py index bd3d17f6..57651b85 100644 --- a/activity_sessions/activities.py +++ b/activity_sessions/activities.py @@ -13,24 +13,32 @@ class ConversationActivities: def __init__( self, worker_identity: str, - load_seconds: float, + fetch_seconds: float, emit: Callable[[StepEvent], None], ) -> None: self.worker_identity = worker_identity - self.load_seconds = load_seconds + self.fetch_seconds = fetch_seconds self.emit = emit - self.cache: dict[str, str] = {} + # Workflow Run ID -> conversation turns this worker holds, always a prefix. + self.cache: dict[str, list[str]] = {} @activity.defn async def conversation_step(self, step: int) -> None: + """Run turn `step`, which needs every earlier turn of the conversation.""" info = activity.info() # Only standalone activities lack workflow IDs; this sample calls it from a workflow. workflow_id = cast(str, info.workflow_id) run_id = cast(str, info.workflow_run_id) - cache_hit = run_id in self.cache - self.emit(StepEvent(workflow_id, step, self.worker_identity, cache_hit, False)) - if not cache_hit: - # Stands in for a slow fetch of conversation history from an external store. - await asyncio.sleep(self.load_seconds) - self.cache[run_id] = f"Conversation context for {workflow_id}" - self.emit(StepEvent(workflow_id, step, self.worker_identity, cache_hit, True)) + turns = self.cache.setdefault(run_id, []) + missing = range(len(turns), step) + self.emit( + StepEvent(workflow_id, step, self.worker_identity, len(missing), False) + ) + for turn in missing: + # Stands in for a slow fetch of one turn from an external conversation store. + await asyncio.sleep(self.fetch_seconds) + turns.append(f"Turn {turn} of {workflow_id}") + turns.append(f"Turn {step} of {workflow_id}") + self.emit( + StepEvent(workflow_id, step, self.worker_identity, len(missing), True) + ) diff --git a/activity_sessions/display.py b/activity_sessions/display.py index 4199ba05..a3bcce34 100644 --- a/activity_sessions/display.py +++ b/activity_sessions/display.py @@ -21,7 +21,10 @@ "bright_green", "bright_yellow", ] -LEGEND = "● context load (miss) ○ cache hit ◌ loading number/color = worker" +LEGEND = ( + "○2 worker 2 had every earlier turn ●2(3) worker 2 fetched 3 missing turns " + "◌ fetching" +) @dataclass @@ -41,6 +44,14 @@ def record(self, event: StepEvent) -> None: raise ValueError(f"Event for unknown workflow {event.workflow_id}") self.steps.setdefault(event.workflow_id, {})[event.step] = event + def cached_turns(self, workflow_id: str) -> dict[str, int]: + """Turns each worker holds for a workflow: everything up to its latest step.""" + held: dict[str, int] = {} + for event in self.steps.get(workflow_id, {}).values(): + if event.finished: + held[event.worker] = max(held.get(event.worker, 0), event.step + 1) + return held + def finished_steps(self) -> list[StepEvent]: return [ event @@ -61,31 +72,36 @@ def worker_color(worker: str) -> str: def placement_strip( batch: Batch, steps: int, worker_count: int, show_pending: bool = False ) -> Table: - """One row per workflow and one cell per step: glyph = cache result, color = worker.""" - width = len(str(worker_count)) + 1 + """One row per workflow and one cell per step: glyph = fetches, color = worker.""" + width = len(f"●{worker_count}({steps - 1})") table = Table(box=None, show_header=False, padding=(0, 1, 0, 0)) table.add_column(style="dim") table.add_column() table.add_column(justify="right") + table.add_column(justify="right", style="dim") for index, workflow_id in enumerate(batch.workflow_ids, 1): events = batch.steps.get(workflow_id, {}) if not events and not show_pending: continue cells = Text() - for step in range(1, steps + 1): + for step in range(steps): event = events.get(step) if event is None: cells.append("·".ljust(width) + " ", style="dim") continue - if event.cache_hit: - glyph = "○" + number = worker_number(event.worker) + if not event.fetched: + cell = f"○{number}" else: - glyph = "●" if event.finished else "◌" - cell = f"{glyph}{worker_number(event.worker)}".ljust(width) + " " - cells.append(cell, style=worker_color(event.worker)) + cell = f"{'●' if event.finished else '◌'}{number}({event.fetched})" + cells.append(cell.ljust(width) + " ", style=worker_color(event.worker)) + fetched = sum(event.fetched for event in events.values()) duration = batch.durations.get(workflow_id) table.add_row( - f"wf-{index:02}", cells, f"{duration:.1f}s" if duration is not None else "" + f"wf-{index:02}", + cells, + f"{duration:.1f}s" if duration is not None else "", + f"{fetched} fetched" if events else "", ) return table @@ -110,18 +126,16 @@ def percentile(values: Sequence[float], fraction: float) -> float: def affinity_violations(batch: Batch) -> list[str]: - """Workflows that did not load once and then reuse the cache on a single worker.""" - violations = [] - for workflow_id in batch.workflow_ids: - events = sorted(batch.steps.get(workflow_id, {}).values(), key=lambda e: e.step) - workers = {event.worker for event in events} - hits = [event.cache_hit for event in events] - if len(workers) != 1 or hits != [False] + [True] * (len(hits) - 1): - violations.append(workflow_id) - return violations - - -def comparison_table(batches: Sequence[Batch], load_seconds: float) -> Table: + """Workflows whose turns did not all run on one worker without fetching.""" + return [ + workflow_id + for workflow_id in batch.workflow_ids + if len(batch.cached_turns(workflow_id)) != 1 + or any(event.fetched for event in batch.steps[workflow_id].values()) + ] + + +def comparison_table(batches: Sequence[Batch], fetch_seconds: float) -> Table: table = Table(title="Impact", box=box.SIMPLE_HEAD) table.add_column("") for batch in batches: @@ -138,11 +152,22 @@ def add( row.append(change or "") table.add_row(*row) - loads = [ - float(sum(not event.cache_hit for event in batch.finished_steps())) + fetched = [ + float(sum(event.fetched for event in batch.finished_steps())) + for batch in batches + ] + fetching_steps = [ + float(sum(bool(event.fetched) for event in batch.finished_steps())) + for batch in batches + ] + fleet_turns = [ + sum( + sum(batch.cached_turns(workflow_id).values()) + for workflow_id in batch.workflow_ids + ) + / len(batch.workflow_ids) for batch in batches ] - copies = [load / len(batch.workflow_ids) for load, batch in zip(loads, batches)] p50 = [percentile(list(batch.durations.values()), 0.5) for batch in batches] p95 = [percentile(list(batch.durations.values()), 0.95) for batch in batches] walls = [batch.wall_seconds for batch in batches] @@ -157,9 +182,15 @@ def faster(values: list[float]) -> str | None: return None return f"{values[0] / values[1]:.1f}x faster" - add("Context loads", loads, ".0f", fewer(loads)) - add("Cache copies per workflow", copies, ".1f") - add("Time spent loading (s)", [load * load_seconds for load in loads], ".1f") + add("Turns fetched", fetched, ".0f", fewer(fetched)) + add("Steps that had to fetch", fetching_steps, ".0f", fewer(fetching_steps)) + add( + "Time spent fetching (s)", + [count * fetch_seconds for count in fetched], + ".1f", + fewer(fetched), + ) + add("Cached turns across fleet per workflow", fleet_turns, ".1f") add("Workflow latency p50 (s)", p50, ".1f", faster(p50)) add("Workflow latency p95 (s)", p95, ".1f", faster(p95)) add("Batch wall time (s)", walls, ".1f", faster(walls)) @@ -167,7 +198,7 @@ def faster(values: list[float]) -> str | None: def summary( - batches: Sequence[Batch], steps: int, worker_count: int, load_seconds: float + batches: Sequence[Batch], steps: int, worker_count: int, fetch_seconds: float ) -> RenderableType: strips = Table.grid(padding=(0, 4)) for batch in batches: @@ -177,7 +208,7 @@ def summary( parts: list[RenderableType] = [ strips, Text(LEGEND, style="dim"), - comparison_table(batches, load_seconds), + comparison_table(batches, fetch_seconds), ] for batch in batches: if not batch.affinity_enabled: @@ -193,8 +224,8 @@ def summary( else: parts.append( Text( - f"Verified: each of {len(batch.workflow_ids)} workflow(s) loaded " - "its context once and reused it on a single worker.", + f"Verified: each of {len(batch.workflow_ids)} workflow(s) ran " + "every turn on one worker without fetching earlier turns.", style="green", ) ) diff --git a/activity_sessions/live.py b/activity_sessions/live.py index 37a74ca9..ac3a099b 100644 --- a/activity_sessions/live.py +++ b/activity_sessions/live.py @@ -40,23 +40,31 @@ def show(self, batch: Batch) -> None: self.live.update(self.render(batch), refresh=True) def render(self, batch: Batch) -> RenderableType: - labels = {workflow_id: i for i, workflow_id in enumerate(batch.workflow_ids, 1)} + held = { + workflow_id: batch.cached_turns(workflow_id) + for workflow_id in batch.workflow_ids + } events = [event for steps in batch.steps.values() for event in steps.values()] panels = [] for worker, session_enabled in self.workers.items(): mine = [event for event in events if event.worker == worker] - cached = sorted( - labels[event.workflow_id] - for event in mine - if not event.cache_hit and event.finished - ) - loading = sum(not event.cache_hit and not event.finished for event in mine) - hits = sum(event.cache_hit for event in mine) + fetched = sum(event.fetched for event in mine if event.finished) + fetching = [event for event in mine if event.fetched and not event.finished] body = Text() - body.append(f"loads {len(cached) + loading} hits {hits}\n") - body.append("◌ " * loading + "\n", style="yellow") + body.append(f"steps {len(mine)} turns fetched {fetched}\n") + body.append( + " ".join(f"◌({event.fetched})" for event in fetching) + "\n", + style="yellow", + ) + # wf:t means this worker holds the workflow's conversation through turn t. body.append("cache: ", style="dim") - body.append(" ".join(f"{label:02}" for label in cached)) + body.append( + " ".join( + f"{index:02}:t{turns[worker] - 1}" + for index, turns in enumerate(held.values(), 1) + if worker in turns + ) + ) title = worker if session_enabled else f"{worker} (activity only)" panels.append( Panel( @@ -65,11 +73,11 @@ def render(self, batch: Batch) -> RenderableType: width=29, ) ) - entries = sum(not event.cache_hit for event in events) + entries = sum(sum(turns.values()) for turns in held.values()) started = len(batch.steps) fleet = Text( - f"Fleet: {entries} cache entries for {started} started workflow(s)" - + (f" ({entries / started:.1f} copies each)" if started else ""), + f"Fleet: {entries} cached turns for {started} started workflow(s)" + + (f" ({entries / started:.1f} per workflow)" if started else ""), style="bold", ) return Group( diff --git a/activity_sessions/run.py b/activity_sessions/run.py index f5d920fa..7cf0eb0f 100644 --- a/activity_sessions/run.py +++ b/activity_sessions/run.py @@ -92,13 +92,13 @@ async def main() -> None: "--workflows", type=int, default=20, help="Concurrent workflows per mode" ) parser.add_argument( - "--steps", type=int, default=5, help="Sequential activities per workflow" + "--steps", type=int, default=6, help="Conversation turns per workflow" ) parser.add_argument( - "--load-seconds", + "--fetch-seconds", type=float, - default=1.0, - help="Simulated latency of loading context on a cache miss", + default=0.5, + help="Simulated latency of fetching one missing conversation turn", ) parser.add_argument( "--no-live", action="store_true", help="Only print the final summary" @@ -132,7 +132,7 @@ async def main() -> None: identity, session_enabled, args.task_queue, - args.load_seconds, + args.fetch_seconds, events, ready, ), @@ -165,7 +165,7 @@ async def main() -> None: for process in processes: process.join() - console.print(summary(batches, args.steps, len(workers), args.load_seconds)) + console.print(summary(batches, args.steps, len(workers), args.fetch_seconds)) if any(affinity_violations(batch) for batch in batches if batch.affinity_enabled): raise SystemExit(1) diff --git a/activity_sessions/worker.py b/activity_sessions/worker.py index e2c9e35d..0524c05c 100644 --- a/activity_sessions/worker.py +++ b/activity_sessions/worker.py @@ -16,14 +16,14 @@ async def run_worker( identity: str, session_enabled: bool, task_queue: str, - load_seconds: float, + fetch_seconds: float, events: "Queue[StepEvent]", ready: Barrier, ) -> None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") client = await Client.connect(**config, identity=identity) - activities = ConversationActivities(identity, load_seconds, events.put) + activities = ConversationActivities(identity, fetch_seconds, events.put) worker = Worker( client, task_queue=task_queue, @@ -43,12 +43,12 @@ def worker_process( identity: str, session_enabled: bool, task_queue: str, - load_seconds: float, + fetch_seconds: float, events: "Queue[StepEvent]", ready: Barrier, ) -> None: # The parent handles Ctrl+C and terminates every worker. signal.signal(signal.SIGINT, signal.SIG_IGN) asyncio.run( - run_worker(identity, session_enabled, task_queue, load_seconds, events, ready) + run_worker(identity, session_enabled, task_queue, fetch_seconds, events, ready) ) diff --git a/activity_sessions/workflows.py b/activity_sessions/workflows.py index e81cd4de..106b7286 100644 --- a/activity_sessions/workflows.py +++ b/activity_sessions/workflows.py @@ -20,7 +20,7 @@ async def run(self, input: ConversationInput) -> None: if input.affinity_enabled else None ) - for step in range(1, input.steps + 1): + for step in range(input.steps): await workflow.execute_activity( "conversation_step", step, diff --git a/tests/activity_sessions/cache_test.py b/tests/activity_sessions/cache_test.py index 17fc13bd..5bcb7701 100644 --- a/tests/activity_sessions/cache_test.py +++ b/tests/activity_sessions/cache_test.py @@ -12,37 +12,55 @@ def environment(run_id: str) -> ActivityEnvironment: return env -def finished_hits(events: list[StepEvent]) -> list[bool]: - return [event.cache_hit for event in events if event.finished] +def fetched(events: list[StepEvent]) -> list[int]: + return [event.fetched for event in events if event.finished] -async def test_same_worker_loads_context_once() -> None: +async def test_first_turn_needs_no_fetch() -> None: + events: list[StepEvent] = [] + activities = ConversationActivities("worker-1", 0, events.append) + await environment("run-1").run(activities.conversation_step, 0) + assert events == [ + StepEvent("workflow-1", 0, "worker-1", 0, False), + StepEvent("workflow-1", 0, "worker-1", 0, True), + ] + assert activities.cache["run-1"] == ["Turn 0 of workflow-1"] + + +async def test_same_worker_never_fetches_its_own_turns() -> None: events: list[StepEvent] = [] activities = ConversationActivities("worker-1", 0, events.append) env = environment("run-1") - for step in (1, 2, 3): + for step in range(4): await env.run(activities.conversation_step, step) - assert finished_hits(events) == [False, True, True] - assert events[0] == StepEvent("workflow-1", 1, "worker-1", False, False) - assert list(activities.cache) == ["run-1"] + assert fetched(events) == [0, 0, 0, 0] + assert len(activities.cache["run-1"]) == 4 -async def test_other_worker_has_its_own_cache() -> None: +async def test_stale_worker_fetches_only_missing_turns() -> None: events: list[StepEvent] = [] - first_worker = ConversationActivities("worker-1", 0, events.append) - other_worker = ConversationActivities("worker-2", 0, events.append) + worker_a = ConversationActivities("worker-1", 0, events.append) + worker_b = ConversationActivities("worker-2", 0, events.append) env = environment("run-1") - await env.run(first_worker.conversation_step, 1) - await env.run(other_worker.conversation_step, 2) - await env.run(first_worker.conversation_step, 3) - assert finished_hits(events) == [False, False, True] - assert first_worker.cache is not other_worker.cache - assert "run-1" in other_worker.cache + await env.run(worker_a.conversation_step, 0) + await env.run(worker_b.conversation_step, 1) + await env.run(worker_a.conversation_step, 2) + # B lacks turn 0; A returns holding turn 0 but not turn 1. + assert fetched(events) == [0, 1, 1] + assert len(worker_a.cache["run-1"]) == 3 + assert len(worker_b.cache["run-1"]) == 2 + + +async def test_cold_worker_fetches_whole_history() -> None: + events: list[StepEvent] = [] + activities = ConversationActivities("worker-1", 0, events.append) + await environment("run-1").run(activities.conversation_step, 4) + assert fetched(events) == [4] -async def test_new_run_starts_cold_on_same_worker() -> None: +async def test_new_run_starts_with_empty_history() -> None: events: list[StepEvent] = [] activities = ConversationActivities("worker-1", 0, events.append) - await environment("run-1").run(activities.conversation_step, 1) + await environment("run-1").run(activities.conversation_step, 0) await environment("run-2").run(activities.conversation_step, 1) - assert finished_hits(events) == [False, False] + assert fetched(events) == [0, 1] diff --git a/tests/activity_sessions/integration_test.py b/tests/activity_sessions/integration_test.py index c1d5ca7a..54fc1378 100644 --- a/tests/activity_sessions/integration_test.py +++ b/tests/activity_sessions/integration_test.py @@ -37,8 +37,8 @@ async def test_demo_cli( "2", "--steps", "3", - "--load-seconds", - "0.2", + "--fetch-seconds", + "0.1", "--no-live", env={ **os.environ, diff --git a/tests/activity_sessions/report_test.py b/tests/activity_sessions/report_test.py index 3a860033..238d5fa6 100644 --- a/tests/activity_sessions/report_test.py +++ b/tests/activity_sessions/report_test.py @@ -12,55 +12,60 @@ ) -def batch(affinity_enabled: bool, placements: dict[str, list[tuple[str, bool]]]): +def batch(affinity_enabled: bool, placements: dict[str, list[tuple[str, int]]]): result = Batch(affinity_enabled, list(placements)) for workflow_id, steps in placements.items(): - for step, (worker, cache_hit) in enumerate(steps, 1): - result.record(StepEvent(workflow_id, step, worker, cache_hit, True)) - result.durations[workflow_id] = sum(not hit for _, hit in steps) * 1.0 + for step, (worker, fetched) in enumerate(steps): + result.record(StepEvent(workflow_id, step, worker, fetched, True)) + result.durations[workflow_id] = 1.0 + sum(fetched for _, fetched in steps) result.wall_seconds = max(result.durations.values()) return result def render(*batches: Batch) -> str: output = io.StringIO() - Console(file=output, width=140, color_system=None).print( + Console(file=output, width=160, color_system=None).print( summary(batches, 3, 2, 1.0) ) return output.getvalue() -def test_comparison_reports_loads_and_speedup() -> None: - off = batch( - False, {"off-1": [("worker-1", False), ("worker-2", False), ("worker-1", True)]} - ) - on = batch( - True, {"on-1": [("worker-2", False), ("worker-2", True), ("worker-2", True)]} - ) +def test_comparison_reports_fetches_and_speedup() -> None: + off = batch(False, {"off-1": [("worker-1", 0), ("worker-2", 1), ("worker-1", 1)]}) + on = batch(True, {"on-1": [("worker-2", 0), ("worker-2", 0), ("worker-2", 0)]}) text = render(off, on) assert "Affinity OFF" in text and "Affinity ON" in text - assert "●1" in text and "○2" in text - assert "50% fewer" in text - assert "2.0x faster" in text + assert "●2(1)" in text and "○2" in text + assert "2 fetched" in text + assert "100% fewer" in text + assert "3.0x faster" in text + # OFF: worker-1 holds turns 0-2, worker-2 holds turns 0-1. + assert "5.0" in text assert "Verified: each of 1 workflow(s)" in text -def test_affinity_violations_flag_split_or_repeated_loads() -> None: +def test_cached_turns_track_each_workers_prefix() -> None: + result = batch(False, {"wf": [("worker-1", 0), ("worker-2", 1), ("worker-1", 1)]}) + assert result.cached_turns("wf") == {"worker-1": 3, "worker-2": 2} + + +def test_affinity_violations_flag_split_or_fetching_workflows() -> None: result = batch( True, { - "good": [("worker-1", False), ("worker-1", True), ("worker-1", True)], - "split": [("worker-1", False), ("worker-2", False), ("worker-1", True)], + "good": [("worker-1", 0), ("worker-1", 0), ("worker-1", 0)], + "split": [("worker-1", 0), ("worker-2", 1), ("worker-1", 1)], + "fetched": [("worker-1", 0), ("worker-1", 0), ("worker-1", 2)], }, ) - assert affinity_violations(result) == ["split"] - assert "Affinity not observed for: split" in render(result) + assert affinity_violations(result) == ["split", "fetched"] + assert "Affinity not observed for: split, fetched" in render(result) def test_unknown_workflow_event_is_rejected() -> None: result = Batch(True, ["known"]) with pytest.raises(ValueError): - result.record(StepEvent("other", 1, "worker-1", False, True)) + result.record(StepEvent("other", 0, "worker-1", 0, True)) def test_worker_configuration_lists_roles() -> None: