Compare commits

...
Author SHA1 Message Date
dependabot[bot] eee468bb57 build(deps): bump the actions group across 1 directory with 4 updates
Bumps the actions group with 4 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action), [hadolint/hadolint-action](https://github.com/hadolint/hadolint-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `hadolint/hadolint-action` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/hadolint/hadolint-action/releases)
- [Commits](https://github.com/hadolint/hadolint-action/compare/2a66e89f53d0771bb131a7fa31f3136336094aa6...06be81baf89a55ffd0e24b8f04a4185738dd3387)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: hadolint/hadolint-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 17:38:29 +00:00
VykosandClaude affaee1e66 fix(discovery): cache a successful but empty Tailscale lookup (#6228)
The host cache was gated on the list being non-empty, so "queried fine, no
eligible peers" looked exactly like a cold cache and every caller paid for
another `tailscale status --json` — a subprocess with a 5s timeout.

Gate on the timestamp instead. Failures still leave the timestamp unset, so a
missing binary, a non-zero exit or unparseable output stays retryable rather
than being cached for the full TTL.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-02 12:05:01 +02:00
daixiheguu ce04dc1db4 fix(tasks): clean up singleflight cache on cancellation (#6174)
Signed-off-by: daixiheguu <daixihegu@outlook.com>
2026-09-01 18:34:50 +02:00
cybernetus@xda 5154bae544 fix(deps): switch psycopg2 to psycopg2-binary (#5937)
Building psycopg2 from source needs libpq-dev/pg_config, which isn't
in the Docker image or most dev hosts, so pip install silently fails
and Postgres users hit ModuleNotFoundError at import time.
2026-09-01 17:49:21 +02:00
8 changed files with 183 additions and 9 deletions
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
persist-credentials: false
- name: Lint Dockerfile
uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: Dockerfile
# DL3008: pinning apt package versions is impractical on a -slim base
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
- name: Upload Trivy results
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-results.sarif
category: trivy-image
+5
View File
@@ -51,3 +51,8 @@ pytest-asyncio
# TestClient import when only classic httpx is present. Runtime code keeps
# using `httpx` above; this is test-client only.
httpx2
# DATABASE_URL defaults to sqlite (core/database.py), but when pointed at an
# external Postgres, SQLAlchemy's postgresql dialect imports psycopg2 inside
# create_engine() and raises ModuleNotFoundError if missing. -binary avoids
# needing libpq-dev/pg_config on the host/image to compile it.
psycopg2-binary
+4 -1
View File
@@ -38,7 +38,10 @@ def discover_tailscale_hosts() -> List[str]:
global _hosts_cache, _hosts_cache_time
now = time.time()
if _hosts_cache and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
# Gate on the timestamp, not the list: a successful query that found no
# eligible peers is a real answer, and testing the list's truthiness made
# that case re-run `tailscale status` (up to a 5s timeout) on every call.
if _hosts_cache_time and (now - _hosts_cache_time) < _HOSTS_CACHE_TTL:
return list(_hosts_cache)
hosts = []
+15 -4
View File
@@ -84,19 +84,30 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
pending = fut
owner = True
if not owner:
return await pending
# A cancelled waiter must not cancel the shared Future for the owner
# and every other waiter.
return await asyncio.shield(pending)
try:
val = await fetch()
async with _shared_cache_lock:
_shared_cache[key] = (time.monotonic() + ttl, val)
_shared_cache_pending.pop(key, None)
pending.set_result(val)
return val
except asyncio.CancelledError:
# Cancellation is a BaseException on supported Python versions, so it
# bypasses the Exception handler below. Wake all current waiters while
# allowing a later caller to retry the fetch.
pending.cancel()
raise
except Exception as e:
async with _shared_cache_lock:
_shared_cache_pending.pop(key, None)
pending.set_exception(e)
raise
finally:
# Keep this cleanup synchronous so a second cancellation cannot
# interrupt it and leave a permanently pending Future behind. All
# access runs on the scheduler's event-loop thread.
if _shared_cache_pending.get(key) is pending:
_shared_cache_pending.pop(key, None)
def compute_next_run(schedule: str, scheduled_time: str,
+69
View File
@@ -0,0 +1,69 @@
"""A successful Tailscale query with no eligible hosts is still cached knowledge.
`discover_tailscale_hosts` gated its cache on the host list being non-empty, so a
valid "nothing to see here" answer looked identical to a cold cache and every
caller paid for another `tailscale status --json` (up to a 5s timeout). Failures
stay uncached so a peer coming online is still picked up promptly.
"""
import pytest
from src import model_discovery
class _Result:
def __init__(self, returncode, stdout):
self.returncode = returncode
self.stdout = stdout
@pytest.fixture
def tailscale(monkeypatch):
"""Count `tailscale status` invocations and start from a cold cache."""
calls = []
def _record(result):
def _run(*_args, **_kwargs):
calls.append(1)
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(model_discovery.subprocess, "run", _run)
return calls
monkeypatch.setattr(model_discovery, "_hosts_cache", [])
monkeypatch.setattr(model_discovery, "_hosts_cache_time", 0)
return _record
def test_empty_but_successful_discovery_is_only_run_once(tailscale):
calls = tailscale(_Result(0, '{"Self":{},"Peer":{}}'))
assert model_discovery.discover_tailscale_hosts() == []
assert model_discovery.discover_tailscale_hosts() == []
assert len(calls) == 1
def test_nonempty_discovery_is_still_cached(tailscale):
calls = tailscale(_Result(0, '{"Self":{"TailscaleIPs":["100.1.1.1"]},"Peer":{}}'))
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
assert model_discovery.discover_tailscale_hosts() == ["100.1.1.1"]
assert len(calls) == 1
@pytest.mark.parametrize(
"result",
[
_Result(1, ""), # tailscale installed but logged out
_Result(0, "not json"), # unparseable output
FileNotFoundError("tailscale"), # not installed
],
ids=["nonzero_exit", "bad_json", "not_installed"],
)
def test_failures_stay_retryable(tailscale, result):
calls = tailscale(result)
assert model_discovery.discover_tailscale_hosts() == []
assert model_discovery.discover_tailscale_hosts() == []
assert len(calls) == 2
+86
View File
@@ -0,0 +1,86 @@
import asyncio
import pytest
from src import task_scheduler
@pytest.fixture(autouse=True)
def clear_shared_cache():
task_scheduler._shared_cache.clear()
task_scheduler._shared_cache_pending.clear()
yield
task_scheduler._shared_cache.clear()
task_scheduler._shared_cache_pending.clear()
async def test_cached_owner_cancellation_wakes_waiters_and_allows_retry():
key = ("cancelled-owner",)
fetch_started = asyncio.Event()
async def blocked_fetch():
fetch_started.set()
await asyncio.Event().wait()
owner = asyncio.create_task(task_scheduler._cached(key, 60, blocked_fetch))
await fetch_started.wait()
async def unexpected_fetch():
pytest.fail("a waiter must share the owner's fetch")
waiter = asyncio.create_task(task_scheduler._cached(key, 60, unexpected_fetch))
await asyncio.sleep(0)
owner.cancel()
with pytest.raises(asyncio.CancelledError):
await owner
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(waiter, timeout=1)
assert key not in task_scheduler._shared_cache_pending
async def retry_fetch():
return "fresh"
result = await asyncio.wait_for(
task_scheduler._cached(key, 60, retry_fetch),
timeout=1,
)
assert result == "fresh"
async def test_cached_waiter_cancellation_does_not_cancel_shared_fetch():
key = ("cancelled-waiter",)
fetch_started = asyncio.Event()
release_fetch = asyncio.Event()
async def blocked_fetch():
fetch_started.set()
await release_fetch.wait()
return "shared"
owner = asyncio.create_task(task_scheduler._cached(key, 60, blocked_fetch))
await fetch_started.wait()
async def unexpected_fetch():
pytest.fail("a waiter must share the owner's fetch")
waiter = asyncio.create_task(task_scheduler._cached(key, 60, unexpected_fetch))
await asyncio.sleep(0)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
pending = task_scheduler._shared_cache_pending[key]
assert not pending.cancelled()
assert not owner.done()
release_fetch.set()
assert await asyncio.wait_for(owner, timeout=1) == "shared"
assert key not in task_scheduler._shared_cache_pending
async def cache_miss():
pytest.fail("the successful owner result should be cached")
assert await task_scheduler._cached(key, 60, cache_miss) == "shared"