Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

# Every job that builds a uv environment sets UV_PYTHON. Without it, `uv run`
# — which every `just` recipe shells out to — resolves its interpreter from
# .python-version (3.14) rather than the matrix version, and DELETES a .venv
# that disagrees, taking the pip-installed extras with it. That is why the
# 3.12/3.13 matrix rows silently tested 3.14 and why typecheck lost pymilvus.
jobs:
claude:
if: |
Expand All @@ -58,6 +63,10 @@ jobs:
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: "3.12"

steps:
- name: Checkout repository
uses: actions/checkout@v6
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/consolidated-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ jobs:
defaults:
run:
working-directory: integrations/hermes
# `uv python install 3.12` only downloads that interpreter; it does not
# select it. Without this, `just check` runs `uv run`, which resolves from
# .python-version (3.14) instead.
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v4

Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ on:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

# Every job that builds a uv environment sets UV_PYTHON. Without it, `uv run`
# — which every `just` recipe shells out to — resolves its interpreter from
# .python-version (3.14) rather than the matrix version, and DELETES a .venv
# that disagrees, taking the pip-installed extras with it. That is why the
# 3.12/3.13 matrix rows silently tested 3.14 and why typecheck lost pymilvus.
jobs:
release:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: "3.12"

steps:
- uses: actions/checkout@v6
with:
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ env:
# to main run the full suite with --testmon-noselect to refresh the baseline.
BASIC_MEMORY_TESTMON_FLAGS: ${{ github.ref_name == 'main' && '--testmon-noselect' || '--testmon --testmon-forceselect' }}

# Every job that builds a uv environment sets UV_PYTHON. Without it, `uv run`
# — which every `just` recipe shells out to — resolves its interpreter from
# .python-version (3.14) rather than the matrix version, and DELETES a .venv
# that disagrees, taking the pip-installed extras with it. That is why the
# 3.12/3.13 matrix rows silently tested 3.14 and why typecheck lost pymilvus.
jobs:
changes:
# Docs/workflow-only changes skip the entire test matrix while the workflow
Expand Down Expand Up @@ -57,6 +62,10 @@ jobs:
timeout-minutes: 20
runs-on: ubuntu-latest

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: "3.12"

steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -111,6 +120,10 @@ jobs:
python-version: "3.12"
runs-on: ${{ matrix.os }}

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: ${{ matrix.python-version }}

steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -172,6 +185,10 @@ jobs:
python-version: "3.12"
runs-on: ${{ matrix.os }}

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: ${{ matrix.python-version }}

steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -245,6 +262,8 @@ jobs:
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
# Pin every uv call, including `uv run` inside just recipes. See top of file.
UV_PYTHON: ${{ matrix.python-version }}

steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -317,6 +336,8 @@ jobs:
--health-retries 5
env:
BASIC_MEMORY_TEST_POSTGRES_URL: postgresql://basic_memory_user:dev_password@127.0.0.1:5432/basic_memory_test
# Pin every uv call, including `uv run` inside just recipes. See top of file.
UV_PYTHON: ${{ matrix.python-version }}

steps:
- uses: actions/checkout@v6
Expand Down Expand Up @@ -367,6 +388,10 @@ jobs:
timeout-minutes: 45
runs-on: ubuntu-latest

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: "3.12"

steps:
- uses: actions/checkout@v6
with:
Expand Down Expand Up @@ -426,6 +451,10 @@ jobs:
python-version: "3.12"
runs-on: ${{ matrix.os }}

# Pin every uv call, including `uv run` inside just recipes. See top of file.
env:
UV_PYTHON: ${{ matrix.python-version }}

steps:
- uses: actions/checkout@v6

Expand Down
8 changes: 7 additions & 1 deletion tests/indexing/test_project_index_maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -1312,7 +1312,13 @@ async def execute(
) -> FakeProjectIndexResult:
if "UPDATE entity" in str(statement):
raise RuntimeError("simulated intra-batch failure")
return await super().execute(statement, params)
# Constraint: `dataclass(slots=True)` cannot add __slots__ in place, so it
# rebuilds the class. Before CPython 3.14 (gh-90562) the rebuilt methods keep
# a `__class__` cell pointing at the discarded original, and zero-argument
# `super()` then rejects `self` with "obj must be an instance or subtype of
# type". Naming the class re-looks it up at call time, which resolves to the
# rebuilt class on every supported interpreter.
return await super(FailingUpdateProjectIndexSession, self).execute(statement, params)


@pytest.mark.asyncio
Expand Down
30 changes: 30 additions & 0 deletions tests/repository/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Shared fixtures for repository-layer tests."""

import os
from collections.abc import Callable

import pytest


@pytest.fixture
def pin_cpu_budget(monkeypatch: pytest.MonkeyPatch) -> Callable[[int], None]:
"""Return a helper that pins the CPU budget the embedding factory observes.

``_available_cpu_count`` prefers ``os.process_cpu_count`` and falls back to
``os.cpu_count``. Both are pinned to the same value, so callers assert on the
resolved budget rather than on which API reported it.

Constraint: ``os.process_cpu_count`` is new in Python 3.13 and
``monkeypatch.setattr`` refuses to set an attribute that does not exist, so
3.12 needs ``raising=False``. Creating it there is deliberate - it keeps the
preferred branch under test on every supported interpreter, and monkeypatch
deletes an attribute it created during teardown, so ``os`` is restored either
way. The genuine 3.12 fallback is covered separately by the tests that delete
``os.process_cpu_count``.
"""

def pin(cpu_count: int) -> None:
monkeypatch.setattr(os, "process_cpu_count", lambda: cpu_count, raising=False)
monkeypatch.setattr(os, "cpu_count", lambda: cpu_count)

return pin
11 changes: 5 additions & 6 deletions tests/repository/test_fastembed_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,9 @@ async def test_fastembed_provider_fails_fast_without_cache_dir(monkeypatch):


@pytest.mark.asyncio
async def test_factory_loads_native_model_once_across_repo_constructions(monkeypatch):
async def test_factory_loads_native_model_once_across_repo_constructions(
monkeypatch, pin_cpu_budget
):
"""The native ONNX model must load exactly once per process despite reuse (#872).

Counting native model loads requires a stub TextEmbedding that increments a
Expand All @@ -614,7 +616,6 @@ async def test_factory_loads_native_model_once_across_repo_constructions(monkeyp
from typing import Any, cast

from basic_memory.config import BasicMemoryConfig, DatabaseBackend, ProjectEntry
from basic_memory.repository import embedding_provider_factory as factory_module
from basic_memory.repository.embedding_provider_factory import (
create_embedding_provider,
reset_embedding_provider_cache,
Expand Down Expand Up @@ -643,13 +644,11 @@ async def test_factory_loads_native_model_once_across_repo_constructions(monkeyp

try:
# First resolution under one CPU budget.
monkeypatch.setattr(factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(factory_module.os, "cpu_count", lambda: 8)
pin_cpu_budget(8)
provider_first = create_embedding_provider(config)

# CPU budget drifts (cgroup throttling) — used to force a second model load.
monkeypatch.setattr(factory_module.os, "process_cpu_count", lambda: 4)
monkeypatch.setattr(factory_module.os, "cpu_count", lambda: 4)
pin_cpu_budget(4)

# Build several repositories the way per-request/per-sync code does. Each
# one is injected with the cached provider rather than deriving its own.
Expand Down
80 changes: 63 additions & 17 deletions tests/repository/test_openai_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,10 +391,11 @@ def test_openai_provider_reports_runtime_log_attrs():
}


def test_embedding_provider_factory_auto_tunes_fastembed_runtime_knobs_from_cpu_budget(monkeypatch):
def test_embedding_provider_factory_auto_tunes_fastembed_runtime_knobs_from_cpu_budget(
pin_cpu_budget,
):
"""Unset FastEmbed runtime knobs should resolve from available CPU budget."""
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 8)
pin_cpu_budget(8)

config = BasicMemoryConfig(
env="test",
Expand All @@ -413,10 +414,9 @@ def test_embedding_provider_factory_auto_tunes_fastembed_runtime_knobs_from_cpu_
assert provider.parallel == 1


def test_embedding_provider_factory_auto_tuning_caps_large_cpu_budgets(monkeypatch):
def test_embedding_provider_factory_auto_tuning_caps_large_cpu_budgets(pin_cpu_budget):
"""Large workers should still leave some headroom and stop at the thread cap."""
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 16)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 16)
pin_cpu_budget(16)

config = BasicMemoryConfig(
env="test",
Expand All @@ -436,11 +436,10 @@ def test_embedding_provider_factory_auto_tuning_caps_large_cpu_budgets(monkeypat


def test_embedding_provider_factory_auto_tuning_stays_conservative_on_small_cpu_budget(
monkeypatch,
pin_cpu_budget,
):
"""Small workers should not get an oversized FastEmbed runtime footprint."""
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 2)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 2)
pin_cpu_budget(2)

config = BasicMemoryConfig(
env="test",
Expand All @@ -459,6 +458,56 @@ def test_embedding_provider_factory_auto_tuning_stays_conservative_on_small_cpu_
assert provider.parallel == 1


def test_embedding_provider_factory_auto_tunes_without_process_cpu_count(monkeypatch):
"""Auto-tuning must land on the same knobs when only os.cpu_count() exists.

``os.process_cpu_count`` is new in Python 3.13, so on 3.12 the factory always
takes the ``os.cpu_count()`` fallback. Deleting the attribute reproduces that
runtime on newer interpreters, where the branch is otherwise unreachable, and
asserts 3.12 resolves the identical 8-CPU budget as the preferred API does.
"""
monkeypatch.delattr(embedding_provider_factory_module.os, "process_cpu_count", raising=False)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 8)

config = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_threads=None,
semantic_embedding_parallel=None,
)

provider = create_embedding_provider(config)

assert isinstance(provider, FastEmbedEmbeddingProvider)
assert provider.threads == 6
assert provider.parallel == 1


def test_embedding_provider_factory_leaves_knobs_unset_when_cpu_budget_is_unknown(monkeypatch):
"""An unreported CPU budget must leave FastEmbed on its own runtime defaults."""
monkeypatch.delattr(embedding_provider_factory_module.os, "process_cpu_count", raising=False)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: None)

config = BasicMemoryConfig(
env="test",
projects={"test-project": "/tmp/basic-memory-test"},
default_project="test-project",
semantic_search_enabled=True,
semantic_embedding_provider="fastembed",
semantic_embedding_threads=None,
semantic_embedding_parallel=None,
)

provider = create_embedding_provider(config)

assert isinstance(provider, FastEmbedEmbeddingProvider)
assert provider.threads is None
assert provider.parallel is None


def test_embedding_provider_factory_reuses_provider_for_same_cache_key():
"""Factory should reuse the same provider instance for identical config values."""
config_a = BasicMemoryConfig(
Expand All @@ -484,10 +533,9 @@ def test_embedding_provider_factory_reuses_provider_for_same_cache_key():
assert provider_a is provider_b


def test_embedding_provider_factory_reuses_auto_tuned_provider_for_same_cpu_budget(monkeypatch):
def test_embedding_provider_factory_reuses_auto_tuned_provider_for_same_cpu_budget(pin_cpu_budget):
"""Auto-tuned FastEmbed providers should still reuse the process cache."""
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 8)
pin_cpu_budget(8)

config_a = BasicMemoryConfig(
env="test",
Expand Down Expand Up @@ -685,7 +733,7 @@ def test_embedding_provider_factory_reuses_provider_when_only_thread_knobs_diffe
assert provider_a is provider_b


def test_embedding_provider_factory_reuses_provider_when_cpu_budget_drifts(monkeypatch):
def test_embedding_provider_factory_reuses_provider_when_cpu_budget_drifts(pin_cpu_budget):
"""A drifting CPU budget between calls must not reload the model (#872).

Simulates a container/cgroup where the auto-tuned thread count changes between
Expand All @@ -702,13 +750,11 @@ def test_embedding_provider_factory_reuses_provider_when_cpu_budget_drifts(monke
semantic_embedding_parallel=None,
)

monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 8)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 8)
pin_cpu_budget(8)
provider_first = create_embedding_provider(config)

# CPU budget shrinks (e.g. cgroup throttling) → auto-tuned thread count changes.
monkeypatch.setattr(embedding_provider_factory_module.os, "process_cpu_count", lambda: 4)
monkeypatch.setattr(embedding_provider_factory_module.os, "cpu_count", lambda: 4)
pin_cpu_budget(4)
provider_second = create_embedding_provider(config)

assert provider_first is provider_second
Expand Down
Loading