wt-runner 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {wt_runner-0.3.2 → wt_runner-0.3.3}/CHANGELOG.md +5 -0
  2. {wt_runner-0.3.2/src/wt_runner.egg-info → wt_runner-0.3.3}/PKG-INFO +1 -1
  3. {wt_runner-0.3.2 → wt_runner-0.3.3}/pyproject.toml +1 -1
  4. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/_version.py +3 -3
  5. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/app.py +31 -1
  6. {wt_runner-0.3.2 → wt_runner-0.3.3/src/wt_runner.egg-info}/PKG-INFO +1 -1
  7. wt_runner-0.3.3/src/wt_runner.egg-info/scm_version.json +8 -0
  8. wt_runner-0.3.2/src/wt_runner.egg-info/scm_version.json +0 -8
  9. {wt_runner-0.3.2 → wt_runner-0.3.3}/README.md +0 -0
  10. {wt_runner-0.3.2 → wt_runner-0.3.3}/setup.cfg +0 -0
  11. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/__init__.py +0 -0
  12. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/py.typed +0 -0
  13. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/testing.py +0 -0
  14. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner/tracing.py +0 -0
  15. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner.egg-info/SOURCES.txt +0 -0
  16. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner.egg-info/dependency_links.txt +0 -0
  17. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner.egg-info/requires.txt +0 -0
  18. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner.egg-info/scm_file_list.json +0 -0
  19. {wt_runner-0.3.2 → wt_runner-0.3.3}/src/wt_runner.egg-info/top_level.txt +0 -0
  20. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/__init__.py +0 -0
  21. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/conftest.py +0 -0
  22. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/test_app.py +0 -0
  23. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/test_gcp_imports.py +0 -0
  24. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/test_testing.py +0 -0
  25. {wt_runner-0.3.2 → wt_runner-0.3.3}/tests/test_tracing.py +0 -0
  26. {wt_runner-0.3.2 → wt_runner-0.3.3}/uv.lock +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.3 — 2026-11-09
4
+
5
+ - `/run-from-pubsub` skips Pub/Sub redeliveries of a run that is already claimed or finished, via a create-only `lock.json` claim in the results location. A claim is never released, so a run whose attempt died is not retried ([#290](https://github.com/wildlife-dynamics/wt/pull/290))
6
+ - Fix the Pub/Sub wait timeout so it is actually capped at `PUBSUB_ACK_MAX_TIMEOUT` (570s); a larger requested timeout previously passed through unchanged ([#290](https://github.com/wildlife-dynamics/wt/pull/290))
7
+
3
8
  ## v0.3.2 — 2026-11-03
4
9
 
5
10
  - Release to pick up the new `wt-invokers-gcp>=0.7.0` floor in `wt-runner-gcp`; no changes to the Python package contents
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wt-runner
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: FastAPI application for workflow execution using wt-invokers
5
5
  License: BSD-3-Clause
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -169,7 +169,7 @@ extend-immutable-calls = [
169
169
 
170
170
  [tool.pixi.package]
171
171
  name = "wt-runner"
172
- version = "0.3.2"
172
+ version = "0.3.3"
173
173
 
174
174
  [tool.pixi.package.build]
175
175
  backend = { name = "pixi-build-python", version = "*", channels = ["conda-forge", "https://prefix.dev/pixi-build-backends"] }
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.3.2'
22
- __version_tuple__ = version_tuple = (0, 3, 2)
21
+ __version__ = version = '0.3.3'
22
+ __version_tuple__ = version_tuple = (0, 3, 3)
23
23
 
24
- __commit_id__ = commit_id = 'gf28c604'
24
+ __commit_id__ = commit_id = 'g43bf668'
@@ -77,6 +77,7 @@ except ImportError:
77
77
  ecoscope_get_results_json = None
78
78
 
79
79
  import obstore
80
+ from obstore.exceptions import AlreadyExistsError
80
81
 
81
82
  # Invoker registry mapping invoker names to classes
82
83
  INVOKERS: dict[str, type[AbstractInvoker]] = {
@@ -92,6 +93,7 @@ TIMEOUT_EXPIRED_ERROR_MSG = (
92
93
  "The workflow timed out. Consider reducing the amount of data being processed."
93
94
  )
94
95
  PUBSUB_ACK_MAX_TIMEOUT = 570 # seconds
96
+ RUN_LOCK_FILENAME = "lock.json"
95
97
 
96
98
 
97
99
  async def get_results_json(results_url: str) -> dict[str, Any]:
@@ -484,6 +486,12 @@ async def run_from_pubsub(
484
486
  "run-from-pubsub-endpoint",
485
487
  attributes=asdict(span_attributes),
486
488
  ):
489
+ if not await claim_run(invoker_params["results_url"]):
490
+ logging.info( # noqa: LOG015 # routed through root logger by design
491
+ "Skipping redelivered run %s: it already has a claim or a result.",
492
+ span_attributes.workflow_run_id,
493
+ )
494
+ return {"status": "skipped"}
487
495
  trace_context = build_context_headers()
488
496
  invoker_params["extra_env"] |= {k.upper(): v for k, v in trace_context.items()}
489
497
  invoker_params["otel_exporter"] = get_otel_exporter()
@@ -507,7 +515,7 @@ async def run_from_pubsub(
507
515
  timeout = command_payload.invoker_kwargs.get("timeout", PUBSUB_ACK_MAX_TIMEOUT)
508
516
  # Maximum timeout when running from PubSub is 10 minutes.
509
517
  # It's set to a little bit less to have time to cancel and handle the error
510
- timeout = min(timeout, max(timeout, PUBSUB_ACK_MAX_TIMEOUT))
518
+ timeout = min(timeout, PUBSUB_ACK_MAX_TIMEOUT)
511
519
  exit_code = await invoker.wait(timeout=timeout, error_msg=TIMEOUT_EXPIRED_ERROR_MSG)
512
520
  if exit_code != 0:
513
521
  raise RuntimeError(f"Workflow invoker failed with exit code {exit_code}.")
@@ -590,6 +598,28 @@ def prepare_invoker_parameters(
590
598
  ) # Extra kwargs are passed to the invoker
591
599
 
592
600
 
601
+ async def claim_run(results_url: str) -> bool:
602
+ """Claim a run for this delivery, or return False if it was already claimed or finished.
603
+
604
+ Pub/Sub push delivers at least once, so a run can arrive again after a deploy or a lost ack.
605
+ A create-only put (GCS ``ifGenerationMatch=0``) lets exactly one delivery win without
606
+ needing delete permission on the results bucket. A claim is never released, so a delivery
607
+ whose attempt died is not retried; the server's run timeout covers that case.
608
+ """
609
+ store = obstore.store.from_url(results_url)
610
+ try:
611
+ await store.head_async("result.json")
612
+ except FileNotFoundError:
613
+ pass
614
+ else:
615
+ return False # finished before claims existed, or the claim's attempt wrote it
616
+ try:
617
+ await store.put_async(RUN_LOCK_FILENAME, b"{}", mode="create")
618
+ except AlreadyExistsError:
619
+ return False
620
+ return True
621
+
622
+
593
623
  async def upload_error_to_gcs(error_details: dict[str, Any], results_url: str) -> None:
594
624
  """Upload error details to Google Cloud Storage.
595
625
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wt-runner
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: FastAPI application for workflow execution using wt-invokers
5
5
  License: BSD-3-Clause
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.3",
3
+ "distance": 0,
4
+ "node": "g43bf668",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-09"
8
+ }
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.2",
3
- "distance": 0,
4
- "node": "gf28c604",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-11-03"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes