Skip to content

Commit a69d8ed

Browse files
committed
fix(worker): fence leases and align grpc runtimes
1 parent 792af6d commit a69d8ed

12 files changed

Lines changed: 599 additions & 125 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
All notable changes to the Spooled Python SDK are documented here.
44

5+
## [1.0.21] - 2026-07-12
6+
7+
### Fixed
8+
9+
- Aligned the optional gRPC runtime and generator floors with the checked-in
10+
generated stubs (`grpcio`/`grpcio-tools` 1.80.0+, protobuf 6.33.5+) and
11+
regenerated the stubs and lockfile.
12+
- Worker and gRPC registration defaults now use the package's canonical version
13+
instead of reporting `1.0.0`; source-only imports retain a synchronized
14+
`1.0.21` fallback when installed package metadata is unavailable.
15+
- Sync and async workers now bind heartbeat, settlement, and cleanup to the exact
16+
active execution, so a stale lease cannot borrow, settle, remove, or cancel a
17+
replacement execution for the same job ID.
18+
- Added regression coverage for bidirectional stream lease fencing, async
19+
heartbeat lease serialization, unary complete/fail/renew wire fields, and
20+
generated-stub imports at the declared minimum dependency versions.
21+
522
## [1.0.20] - 2026-07-11
623

724
### Added

‎pyproject.toml‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "spooled"
7-
version = "1.0.20"
7+
version = "1.0.21"
88
description = "Official Python SDK for Spooled Cloud job queue service"
99
readme = "README.md"
1010
license = "Apache-2.0"
@@ -41,12 +41,12 @@ dependencies = [
4141

4242
[project.optional-dependencies]
4343
grpc = [
44-
"grpcio>=1.60.0",
45-
"grpcio-tools>=1.60.0",
46-
# Security floor: CVE-2026-0994 (JSON recursion depth bypass) is fixed in
47-
# protobuf 5.29.6 (and 6.33.5 on the 6.x line); older floors permitted
48-
# vulnerable releases.
49-
"protobuf>=5.29.6",
44+
# Keep runtime floors aligned with the checked-in generated stubs.
45+
"grpcio>=1.80.0",
46+
"grpcio-tools>=1.80.0",
47+
# Generated protobuf code requires 6.31.1+. Use the patched 6.x security
48+
# floor for CVE-2026-0994 rather than permitting vulnerable 6.x releases.
49+
"protobuf>=6.33.5",
5050
]
5151
realtime = [
5252
"websockets>=12.0",
@@ -73,6 +73,9 @@ Documentation = "https://spooled.cloud/docs/"
7373
Repository = "https://github.com/spooled-cloud/spooled-sdk-python"
7474
Changelog = "https://github.com/spooled-cloud/spooled-sdk-python/blob/main/CHANGELOG.md"
7575

76+
[tool.hatch.build]
77+
artifacts = ["src/spooled/grpc/stubs"]
78+
7679
[tool.hatch.build.targets.wheel]
7780
packages = ["src/spooled"]
7881

‎scripts/generate_grpc.sh‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,19 @@ python -m grpc_tools.protoc \
1919
--pyi_out="$OUTPUT_DIR" \
2020
"$PROTO_DIR/spooled.proto"
2121

22-
# Create __init__.py
23-
cat > "$OUTPUT_DIR/__init__.py" << 'EOF'
24-
"""Generated gRPC stubs."""
25-
from spooled.grpc.stubs.spooled_pb2 import *
26-
from spooled.grpc.stubs.spooled_pb2_grpc import *
27-
EOF
22+
# grpcio-tools generates a top-level sibling import; rewrite it for this package.
23+
python - "$OUTPUT_DIR/spooled_pb2_grpc.py" << 'PY'
24+
from pathlib import Path
25+
import sys
26+
27+
path = Path(sys.argv[1])
28+
content = path.read_text()
29+
content = content.replace(
30+
"import spooled_pb2 as spooled__pb2",
31+
"from spooled.grpc.stubs import spooled_pb2 as spooled__pb2",
32+
)
33+
path.write_text(content)
34+
PY
35+
2836

2937
echo "gRPC stubs generated successfully in $OUTPUT_DIR"

‎src/spooled/__init__.py‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
>>> print(f"Created job: {result.id}")
1414
"""
1515

16+
from spooled._version import __version__
1617
from spooled.config import (
1718
CircuitBreakerConfig,
1819
RetryConfig,
@@ -35,15 +36,6 @@
3536
is_spooled_error,
3637
)
3738

38-
# Version — resolved from the installed package metadata so it never drifts from
39-
# the released version (the previous hardcoded literal lagged the real release).
40-
try:
41-
from importlib.metadata import version as _pkg_version
42-
43-
__version__ = _pkg_version("spooled")
44-
except Exception: # pragma: no cover - source/editable checkout without metadata
45-
__version__ = "1.0.20"
46-
4739
# These will be imported when the client modules are created
4840
__all__ = [
4941
# Version

‎src/spooled/_version.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Canonical access to the installed Spooled SDK package version."""
2+
3+
from importlib.metadata import PackageNotFoundError, version
4+
5+
try:
6+
__version__ = version("spooled")
7+
except PackageNotFoundError: # pragma: no cover - source checkout without installed metadata
8+
__version__ = "1.0.21"

‎src/spooled/grpc/client.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from google.protobuf import struct_pb2, timestamp_pb2
1919
from pydantic import BaseModel, Field
2020

21+
from spooled._version import __version__
22+
2123
# Optional gRPC imports
2224
try:
2325
import grpc
@@ -179,7 +181,7 @@ class GrpcRegisterWorkerRequest(BaseModel):
179181
hostname: str
180182
worker_type: str = "python"
181183
max_concurrency: int = Field(default=5, ge=1, le=100)
182-
version: str = "1.0.0"
184+
version: str = __version__
183185
metadata: dict[str, str] | None = None
184186

185187
model_config = {"extra": "forbid"}
@@ -899,7 +901,7 @@ def register(
899901
*,
900902
worker_type: str = "python",
901903
max_concurrency: int = 5,
902-
version: str = "1.0.0",
904+
version: str = __version__,
903905
metadata: dict[str, str] | None = None,
904906
) -> GrpcRegisterWorkerResponse:
905907
"""

‎src/spooled/worker/async_worker.py‎

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass
1414
from typing import TYPE_CHECKING, Any
1515

16+
from spooled._version import __version__
1617
from spooled.types.jobs import ClaimedJob
1718
from spooled.worker.types import (
1819
AsyncJobContext,
@@ -75,7 +76,7 @@ def __init__(
7576
shutdown_timeout: float = 30.0,
7677
hostname: str | None = None,
7778
worker_type: str = "python",
78-
version: str = "1.0.0",
79+
version: str = __version__,
7980
metadata: dict[str, Any] | None = None,
8081
) -> None:
8182
"""
@@ -116,6 +117,7 @@ def __init__(
116117
self._worker_heartbeat_task: asyncio.Task[None] | None = None
117118
self._shutdown_event: asyncio.Event | None = None
118119
self._semaphore: asyncio.Semaphore | None = None
120+
self._active_jobs_lock = asyncio.Lock()
119121

120122
# Event handlers
121123
self._event_handlers: dict[WorkerEvent, list[Callable[..., Any]]] = {}
@@ -289,7 +291,7 @@ async def stop(self) -> None:
289291
for job_id, active in list(self._active_jobs.items()):
290292
if self._debug:
291293
self._debug(f"Force-failing job {job_id} due to shutdown timeout", None)
292-
await self._fail_job(active.job, "Worker shutdown timeout")
294+
await self._fail_job(active, "Worker shutdown timeout")
293295

294296
# Deregister worker
295297
if self._worker_id:
@@ -352,14 +354,15 @@ async def _process_job(self, job: ClaimedJob) -> None:
352354
abort_event=abort_event,
353355
)
354356

355-
self._active_jobs[job.id] = active
357+
async with self._active_jobs_lock:
358+
self._active_jobs[job.id] = active
356359

357360
# Start per-job heartbeat
358361
heartbeat_interval = (
359362
self._options.lease_duration * self._options.heartbeat_fraction
360363
)
361364
active.heartbeat_task = asyncio.create_task(
362-
self._job_heartbeat_loop(job.id, heartbeat_interval)
365+
self._job_heartbeat_loop(active, heartbeat_interval)
363366
)
364367

365368
# Execute handler
@@ -394,7 +397,7 @@ async def _execute_handler(self, active: ActiveJob) -> None:
394397
return
395398

396399
# Complete the job
397-
await self._complete_job(job, result)
400+
await self._complete_job(active, result)
398401

399402
except Exception as e:
400403
# Check if aborted
@@ -404,25 +407,30 @@ async def _execute_handler(self, active: ActiveJob) -> None:
404407
return
405408

406409
error_message = str(e)
407-
await self._fail_job(job, error_message)
410+
await self._fail_job(active, error_message)
408411

409412
finally:
410-
await self._cleanup_job(job.id)
411-
412-
async def _complete_job(self, job: ClaimedJob, result: dict[str, Any] | None) -> None:
413-
"""Complete a job."""
414-
if not self._worker_id:
415-
return
413+
await self._cleanup_job(active)
416414

415+
async def _complete_job(self, active: ActiveJob, result: dict[str, Any] | None) -> None:
416+
"""Complete an active execution only while it still owns the job slot."""
417+
job = active.job
417418
try:
419+
async with self._active_jobs_lock:
420+
worker_id = self._worker_id
421+
if self._active_jobs.get(job.id) is not active or not worker_id:
422+
return
418423
params: dict[str, Any] = {
419-
"worker_id": self._worker_id,
424+
"worker_id": worker_id,
420425
"result": result,
421426
}
422427
if job.lease_id is not None:
423428
params["lease_id"] = job.lease_id
424429
await self._client.jobs.complete(job.id, params)
425430

431+
async with self._active_jobs_lock:
432+
if self._active_jobs.get(job.id) is not active:
433+
return
426434
self._emit("job:completed", JobCompletedEventData(
427435
job_id=job.id,
428436
queue_name=job.queue_name,
@@ -433,22 +441,27 @@ async def _complete_job(self, job: ClaimedJob, result: dict[str, Any] | None) ->
433441
if self._debug:
434442
self._debug(f"Failed to complete job {job.id}: {e}", None)
435443

436-
async def _fail_job(self, job: ClaimedJob, error_message: str) -> None:
437-
"""Fail a job."""
438-
if not self._worker_id:
439-
return
440-
444+
async def _fail_job(self, active: ActiveJob, error_message: str) -> None:
445+
"""Fail an active execution only while it still owns the job slot."""
446+
job = active.job
441447
will_retry = job.retry_count < job.max_retries
442448

443449
try:
450+
async with self._active_jobs_lock:
451+
worker_id = self._worker_id
452+
if self._active_jobs.get(job.id) is not active or not worker_id:
453+
return
444454
params: dict[str, Any] = {
445-
"worker_id": self._worker_id,
455+
"worker_id": worker_id,
446456
"error": error_message,
447457
}
448458
if job.lease_id is not None:
449459
params["lease_id"] = job.lease_id
450460
await self._client.jobs.fail(job.id, params)
451461

462+
async with self._active_jobs_lock:
463+
if self._active_jobs.get(job.id) is not active:
464+
return
452465
self._emit("job:failed", JobFailedEventData(
453466
job_id=job.id,
454467
queue_name=job.queue_name,
@@ -460,36 +473,42 @@ async def _fail_job(self, job: ClaimedJob, error_message: str) -> None:
460473
if self._debug:
461474
self._debug(f"Failed to fail job {job.id}: {e}", None)
462475

463-
async def _cleanup_job(self, job_id: str) -> None:
464-
"""Clean up after job completion."""
465-
active = self._active_jobs.pop(job_id, None)
466-
if active and active.heartbeat_task:
476+
async def _cleanup_job(self, active: ActiveJob) -> None:
477+
"""Clean up only the exact execution that finished."""
478+
async with self._active_jobs_lock:
479+
if self._active_jobs.get(active.job.id) is active:
480+
del self._active_jobs[active.job.id]
481+
if active.heartbeat_task and active.heartbeat_task is not asyncio.current_task():
467482
active.heartbeat_task.cancel()
468483
try:
469484
await active.heartbeat_task
470485
except asyncio.CancelledError:
471486
pass
472487

473-
async def _job_heartbeat_loop(self, job_id: str, interval: float) -> None:
474-
"""Job heartbeat loop."""
475-
while job_id in self._active_jobs and self._worker_id:
488+
async def _job_heartbeat_loop(self, active: ActiveJob, interval: float) -> None:
489+
"""Send heartbeats bound to one immutable execution."""
490+
job = active.job
491+
while True:
476492
await asyncio.sleep(interval)
477493

478-
active = self._active_jobs.get(job_id)
479-
if active is None or not self._worker_id:
480-
break
481-
482494
try:
495+
async with self._active_jobs_lock:
496+
worker_id = self._worker_id
497+
if self._active_jobs.get(job.id) is not active or not worker_id:
498+
break
483499
params: dict[str, Any] = {
484-
"worker_id": self._worker_id,
500+
"worker_id": worker_id,
485501
"lease_duration_secs": self._options.lease_duration,
486502
}
487-
if active.job.lease_id is not None:
488-
params["lease_id"] = active.job.lease_id
489-
await self._client.jobs.heartbeat(job_id, params)
503+
if job.lease_id is not None:
504+
params["lease_id"] = job.lease_id
505+
await self._client.jobs.heartbeat(job.id, params)
506+
async with self._active_jobs_lock:
507+
if self._active_jobs.get(job.id) is not active:
508+
break
490509
except Exception as e:
491510
if self._debug:
492-
self._debug(f"Job heartbeat failed for {job_id}: {e}", None)
511+
self._debug(f"Job heartbeat failed for {job.id}: {e}", None)
493512

494513
async def _worker_heartbeat_loop(self, interval: float) -> None:
495514
"""Worker heartbeat loop."""

0 commit comments

Comments
 (0)