174 lines
5.4 KiB
Python
174 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from threading import Barrier
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from cloud.database import CloudDatabase
|
|
from cloud.pool import PooledDevice
|
|
from cloud.scheduler import ScheduledTask, TaskConstraints
|
|
|
|
|
|
def _unique_id(prefix: str) -> str:
|
|
return f"{prefix}-{uuid4().hex}"
|
|
|
|
|
|
def _seed_assignment_candidate(
|
|
database: CloudDatabase,
|
|
*,
|
|
now: datetime,
|
|
) -> tuple[str, str, str]:
|
|
host_id = _unique_id("concurrency-host")
|
|
device_id = _unique_id("concurrency-device")
|
|
task_id = _unique_id("concurrency-task")
|
|
database.repository.upsert_host(host_id, address=None, last_seen_at=now)
|
|
database.repository.replace_host_devices(
|
|
host_id,
|
|
[
|
|
PooledDevice(
|
|
device_id=device_id,
|
|
host_id=host_id,
|
|
driver_type="wda",
|
|
status="idle",
|
|
synced_at=now,
|
|
)
|
|
],
|
|
)
|
|
database.repository.enqueue_task(
|
|
ScheduledTask(
|
|
id=task_id,
|
|
goal="concurrent operation",
|
|
workflow_definition_id=None,
|
|
constraints=TaskConstraints(),
|
|
created_at=now,
|
|
)
|
|
)
|
|
return host_id, device_id, task_id
|
|
|
|
|
|
def _postgres_url() -> str:
|
|
url = os.getenv("TEST_POSTGRES_URL")
|
|
if not url:
|
|
pytest.skip("TEST_POSTGRES_URL is required for PostgreSQL concurrency tests")
|
|
return url
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_postgresql_concurrent_assignment_has_one_winner() -> None:
|
|
database_url = _postgres_url()
|
|
setup = CloudDatabase(database_url)
|
|
now = datetime(2026, 7, 12, 17, 0, tzinfo=UTC)
|
|
host_id, device_id, task_id = _seed_assignment_candidate(setup, now=now)
|
|
setup.close()
|
|
barrier = Barrier(2)
|
|
|
|
def assign(lease_id: str):
|
|
contender = CloudDatabase(database_url, create_schema=False)
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
return contender.repository.assign_task(
|
|
task_id=task_id,
|
|
host_id=host_id,
|
|
device_id=device_id,
|
|
lease_id=lease_id,
|
|
lease_expires_at=now + timedelta(minutes=1),
|
|
now=now,
|
|
)
|
|
finally:
|
|
contender.close()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
results = list(executor.map(assign, ("lease-a", "lease-b")))
|
|
|
|
assert sum(result is not None for result in results) == 1
|
|
verification = CloudDatabase(database_url, create_schema=False)
|
|
try:
|
|
task = verification.repository.get_task(task_id)
|
|
attempts = verification.repository.list_task_attempts(task_id)
|
|
assert task is not None
|
|
assert task.status == "assigned"
|
|
assert task.attempt_count == 1
|
|
assert len(attempts) == 1
|
|
assert attempts[0].lease_id in {"lease-a", "lease-b"}
|
|
finally:
|
|
verification.close()
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_postgresql_concurrent_claim_has_one_winner() -> None:
|
|
database_url = _postgres_url()
|
|
setup = CloudDatabase(database_url)
|
|
now = datetime(2026, 7, 12, 18, 0, tzinfo=UTC)
|
|
host_id, device_id, task_id = _seed_assignment_candidate(setup, now=now)
|
|
setup.repository.assign_task(
|
|
task_id=task_id,
|
|
host_id=host_id,
|
|
device_id=device_id,
|
|
lease_id="claim-race-lease",
|
|
lease_expires_at=now + timedelta(minutes=1),
|
|
now=now,
|
|
)
|
|
setup.close()
|
|
barrier = Barrier(2)
|
|
|
|
def claim():
|
|
contender = CloudDatabase(database_url, create_schema=False)
|
|
try:
|
|
barrier.wait(timeout=5)
|
|
return contender.repository.claim_assignment(host_id=host_id, now=now)
|
|
finally:
|
|
contender.close()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
results = list(executor.map(lambda _index: claim(), range(2)))
|
|
|
|
assert sum(result is not None for result in results) == 1
|
|
verification = CloudDatabase(database_url, create_schema=False)
|
|
try:
|
|
task = verification.repository.get_task(task_id)
|
|
attempts = verification.repository.list_task_attempts(task_id)
|
|
assert task is not None
|
|
assert task.status == "dispatched"
|
|
assert len(attempts) == 1
|
|
assert attempts[0].status == "dispatched"
|
|
finally:
|
|
verification.close()
|
|
|
|
|
|
def test_sqlite_single_control_plane_rejects_sequential_duplicate_assignment(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
database = CloudDatabase(f"sqlite:///{(tmp_path / 'single.sqlite3').as_posix()}")
|
|
now = datetime(2026, 7, 12, 19, 0, tzinfo=UTC)
|
|
host_id, device_id, task_id = _seed_assignment_candidate(database, now=now)
|
|
|
|
try:
|
|
first = database.repository.assign_task(
|
|
task_id=task_id,
|
|
host_id=host_id,
|
|
device_id=device_id,
|
|
lease_id="sqlite-lease-a",
|
|
lease_expires_at=now + timedelta(minutes=1),
|
|
now=now,
|
|
)
|
|
second = database.repository.assign_task(
|
|
task_id=task_id,
|
|
host_id=host_id,
|
|
device_id=device_id,
|
|
lease_id="sqlite-lease-b",
|
|
lease_expires_at=now + timedelta(minutes=1),
|
|
now=now,
|
|
)
|
|
|
|
assert database.engine.dialect.name == "sqlite"
|
|
assert first is not None
|
|
assert second is None
|
|
assert len(database.repository.list_task_attempts(task_id)) == 1
|
|
finally:
|
|
database.close()
|