Skip to content

fix(storage): unify SQLite + graph writes behind one process-level lock #141

fix(storage): unify SQLite + graph writes behind one process-level lock

fix(storage): unify SQLite + graph writes behind one process-level lock #141

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Lint (ruff)
run: ruff check src/
- name: Format check (ruff)
run: ruff format --check src/
- name: Type check (mypy)
run: mypy src/hebb/
# Fast, deterministic gate: unit + integration + eval-harness tests on every
# OS/Python cell. No model download — model-dependent tests are `-m slow` and
# run only in the `model` job below.
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Unit + integration + eval (no model)
run: >-
pytest tests/unit tests/integration tests/eval
-m "not slow" --tb=short
--cov=hebb --cov-report=xml --cov-report=term
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-${{ matrix.os }}-py${{ matrix.python-version }}
path: coverage.xml
if-no-files-found: ignore
# Black-box tests that spawn the installed `hebb` / `hebb-mcp` entrypoints.
# POSIX-only surfaces (stdio guard, service managers) — Windows is gated out;
# individual tests carry their own skipif as a second guard.
e2e:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
fail-fast: false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: pip install -e ".[dev]"
- name: E2E (subprocess hebb / hebb-mcp)
run: pytest tests/e2e -m "not slow" --tb=short
# Exercises the REAL embedding + cross-encoder rerank + vector path that the
# offline matrix can't — the retrieval core that's most actively tuned.
# HF weights are cached across runs and keyed on the model ids.
model:
runs-on: ubuntu-latest
env:
HF_HOME: ${{ github.workspace }}/.hf
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
cache-dependency-path: pyproject.toml
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ${{ env.HF_HOME }}
key: hf-${{ runner.os }}-minilm-msmarco-reranker
restore-keys: hf-${{ runner.os }}-
# On a cache miss the warm step hits HF anonymously from a shared CI IP
# and routinely gets 429 Too Many Requests — snapshot_download's repo_info
# call does not retry on 429, so one rate-limit killed the whole job.
# Retry with backoff (honoring Retry-After) and authenticate when an
# optional HF_TOKEN secret is configured (far higher per-token limit).
- name: Warm model cache
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_ETAG_TIMEOUT: "60"
run: |
python - <<'PY'
import os
import sys
import time
from huggingface_hub import snapshot_download
try: # moved to huggingface_hub.errors in newer releases
from huggingface_hub.errors import HfHubHTTPError
except ImportError: # pragma: no cover - older huggingface_hub
from huggingface_hub.utils import HfHubHTTPError
REPOS = (
"sentence-transformers/all-MiniLM-L6-v2",
"cross-encoder/ms-marco-MiniLM-L-6-v2",
)
TOKEN = os.environ.get("HF_TOKEN") or None
MAX_ATTEMPTS = 6
# Back off on rate-limit / transient server errors only; a 401/404 is
# a real config error and should fail fast.
RETRY_STATUS = {429, 500, 502, 503, 504}
def warm(repo: str) -> None:
delay = 10.0
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
snapshot_download(repo, token=TOKEN, etag_timeout=60)
print(f"[warm] {repo}: ok (attempt {attempt})", flush=True)
return
except HfHubHTTPError as err:
resp = getattr(err, "response", None)
status = getattr(resp, "status_code", None)
if status not in RETRY_STATUS or attempt == MAX_ATTEMPTS:
raise
retry_after = resp.headers.get("Retry-After") if resp is not None else None
wait = float(retry_after) if retry_after and retry_after.isdigit() else delay
wait += (attempt * 1.7) % 5 # deterministic jitter, no RNG
print(
f"[warm] {repo}: HTTP {status}, retry {attempt}/{MAX_ATTEMPTS} in {wait:.0f}s",
file=sys.stderr,
flush=True,
)
time.sleep(wait)
delay = min(delay * 2, 120)
for repo in REPOS:
warm(repo)
PY
- name: Model-dependent tests (real vector path, offline read from cache)
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
run: pytest -m slow --tb=short
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -f docker/Dockerfile -t hebb:test .