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
26 changes: 26 additions & 0 deletions .semversioner/3.2.0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"changes": [
{
"description": "Add a concurrency-safe SQLite cache provider.",
"type": "minor"
},
{
"description": "Fix broken doc links and doc string parameter mismatches.",
"type": "patch"
},
{
"description": "Update dependencies to latest versions (dependency sweep).",
"type": "patch"
},
{
"description": "Update dependencies to latest versions (dependency sweep).",
"type": "patch"
},
{
"description": "Update dependencies to latest versions.",
"type": "patch"
}
],
"created_at": "2026-09-23T19:31:47+00:00",
"version": "3.2.0"
}
4 changes: 0 additions & 4 deletions .semversioner/next-release/patch-20260824002403308803.json

This file was deleted.

4 changes: 0 additions & 4 deletions .semversioner/next-release/patch-20260824180455220107.json

This file was deleted.

4 changes: 0 additions & 4 deletions .semversioner/next-release/patch-20260916163732844658.json

This file was deleted.

4 changes: 0 additions & 4 deletions .semversioner/next-release/patch-20260921004015343838.json

This file was deleted.

8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# Changelog
Note: version releases in the 0.x.y range may introduce breaking changes.

## 3.2.0

- minor: Add a concurrency-safe SQLite cache provider.
- patch: Fix broken doc links and doc string parameter mismatches.
- patch: Update dependencies to latest versions (dependency sweep).
- patch: Update dependencies to latest versions (dependency sweep).
- patch: Update dependencies to latest versions.

## 3.1.2

- patch: Change default package feed index.
Expand Down
18 changes: 18 additions & 0 deletions packages/graphrag-cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ By default, the `create_cache` comes with the following cache providers register
- `JsonCache`
- `MemoryCache`
- `NoopCache`
- `SQLiteCache`

`SQLiteCache` stores JSON-serializable values in a local SQLite database. It uses
WAL mode and short-lived connections so multiple workers can safely share the
same database. Configure it with:

```yaml
cache:
type: sqlite
storage:
type: file
base_dir: cache
database_name: cache.db
```

The database is created within the configured file storage. SQLite requires
local random-access file operations, so blob and Cosmos storage are not
supported. Child caches use isolated namespaces within the same database.

The preregistration happens dynamically, e.g., `JsonCache` is only imported and registered if you request a `JsonCache` with `create_cache(CacheType.Json, ...)`. There is no need to manually import and register builtin cache providers when using `create_cache`.

Expand Down
20 changes: 17 additions & 3 deletions packages/graphrag-cache/graphrag_cache/cache_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""Cache configuration model."""

from graphrag_storage import StorageConfig, StorageType
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator

from graphrag_cache.cache_type import CacheType

Expand All @@ -16,11 +16,25 @@ class CacheConfig(BaseModel):
"""Allow extra fields to support custom cache implementations."""

type: str = Field(
description="The cache type to use. Builtin types include 'Json', 'Memory', and 'Noop'.",
description="The cache type to use. Builtin types include 'Json', 'Memory', 'Noop', and 'Sqlite'.",
default=CacheType.Json,
)

storage: StorageConfig | None = Field(
description="The storage configuration to use for file-based caches such as 'Json'.",
description="The storage configuration to use for storage-backed caches such as 'Json' and 'Sqlite'.",
default_factory=lambda: StorageConfig(type=StorageType.File, base_dir="cache"),
)

database_name: str = Field(
description="The SQLite database name within the configured file storage. Used only when type is 'Sqlite'.",
default="cache.db",
)

@model_validator(mode="after")
def _validate_sqlite_storage(self) -> "CacheConfig":
if self.type == CacheType.Sqlite and (
self.storage is None or self.storage.type != StorageType.File
):
msg = "Cache type 'sqlite' requires storage type 'file'."
raise ValueError(msg)
return self
5 changes: 5 additions & 0 deletions packages/graphrag-cache/graphrag_cache/cache_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ def create_cache(

register_cache(CacheType.Noop, NoopCache)

case CacheType.Sqlite:
from graphrag_cache.sqlite_cache import SQLiteCache

register_cache(CacheType.Sqlite, SQLiteCache)

case _:
msg = f"CacheConfig.type '{cache_strategy}' is not registered in the CacheFactory. Registered types: {', '.join(cache_factory.keys())}."
raise ValueError(msg)
Expand Down
1 change: 1 addition & 0 deletions packages/graphrag-cache/graphrag_cache/cache_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ class CacheType(StrEnum):
Json = "json"
Memory = "memory"
Noop = "none"
Sqlite = "sqlite"
192 changes: 192 additions & 0 deletions packages/graphrag-cache/graphrag_cache/sqlite_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Copyright (c) 2024 Microsoft Corporation.
# Licensed under the MIT License

"""SQLite cache implementation."""

from __future__ import annotations

import asyncio
import json
import sqlite3
from contextlib import contextmanager
from pathlib import PurePosixPath, PureWindowsPath
from typing import TYPE_CHECKING, Any

from graphrag_storage import Storage, StorageConfig, create_storage
from graphrag_storage.file_storage import FileStorage
from graphrag_storage.memory_storage import MemoryStorage

from graphrag_cache.cache import Cache

if TYPE_CHECKING:
from collections.abc import Generator


class SQLiteCache(Cache):
"""A concurrency-safe, namespaced cache backed by SQLite."""

def __init__(
self,
storage: Storage | dict[str, Any] | None = None,
database_name: str = "cache.db",
*,
namespace: str = "",
**_: Any,
) -> None:
"""Initialize the SQLite cache."""
if storage is None:
msg = "SQLiteCache requires either a Storage instance to be provided or a StorageConfig to create one."
raise ValueError(msg)
if not isinstance(storage, Storage):
storage = create_storage(StorageConfig(**storage))
if not isinstance(storage, FileStorage) or isinstance(storage, MemoryStorage):
msg = "SQLiteCache only supports FileStorage."
raise TypeError(msg)
database_paths = (
PurePosixPath(database_name),
PureWindowsPath(database_name),
)
if (
not database_name
or database_name == ".."
or any(
path.is_absolute() or len(path.parts) != 1 or path.name != database_name
for path in database_paths
)
):
msg = "SQLiteCache database_name must be a file name without a directory."
raise ValueError(msg)
self._storage = storage
self._database_name = database_name
self._database_path = storage.get_path(database_name)
self._namespace = namespace
self._initialize()

@contextmanager
def _connect(self) -> Generator[sqlite3.Connection]:
connection = sqlite3.connect(self._database_path, timeout=30)
connection.execute("PRAGMA busy_timeout = 30000")
try:
with connection:
yield connection
finally:
connection.close()

def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
connection.execute("PRAGMA synchronous = NORMAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS cache_entries (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value_json TEXT NOT NULL,
PRIMARY KEY (namespace, key)
)
"""
)

async def get(self, key: str) -> Any | None:
"""Get the value for the given key."""
payload = await asyncio.to_thread(self._get, key)
if payload is None:
return None
try:
data = json.loads(payload)
except json.JSONDecodeError:
await asyncio.to_thread(self._delete_if_unchanged, key, payload)
return None
if not isinstance(data, dict) or "result" not in data:
await asyncio.to_thread(self._delete_if_unchanged, key, payload)
return None
return data["result"]

def _get(self, key: str) -> str | None:
with self._connect() as connection:
row = connection.execute(
"""
SELECT value_json
FROM cache_entries
WHERE namespace = ? AND key = ?
""",
(self._namespace, key),
).fetchone()
return str(row[0]) if row is not None else None

async def set(self, key: str, value: Any, debug_data: dict | None = None) -> None:
"""Set the value for the given key."""
if value is None:
return
data = {"result": value, **(debug_data or {})}
payload = json.dumps(data, ensure_ascii=False)
await asyncio.to_thread(self._set, key, payload)

def _set(self, key: str, payload: str) -> None:
with self._connect() as connection:
connection.execute(
"""
INSERT INTO cache_entries(namespace, key, value_json)
VALUES (?, ?, ?)
ON CONFLICT(namespace, key)
DO UPDATE SET value_json = excluded.value_json
""",
(self._namespace, key, payload),
)

async def has(self, key: str) -> bool:
"""Return whether the given key exists in the cache."""
return await asyncio.to_thread(self._has, key)

def _has(self, key: str) -> bool:
with self._connect() as connection:
row = connection.execute(
"""
SELECT 1
FROM cache_entries
WHERE namespace = ? AND key = ?
""",
(self._namespace, key),
).fetchone()
return row is not None

async def delete(self, key: str) -> None:
"""Delete the given key from the cache."""
await asyncio.to_thread(self._delete, key)

def _delete(self, key: str) -> None:
with self._connect() as connection:
connection.execute(
"DELETE FROM cache_entries WHERE namespace = ? AND key = ?",
(self._namespace, key),
)

def _delete_if_unchanged(self, key: str, payload: str) -> None:
with self._connect() as connection:
connection.execute(
"""
DELETE FROM cache_entries
WHERE namespace = ? AND key = ? AND value_json = ?
""",
(self._namespace, key, payload),
)

async def clear(self) -> None:
"""Clear this cache namespace."""
await asyncio.to_thread(self._clear)

def _clear(self) -> None:
with self._connect() as connection:
connection.execute(
"DELETE FROM cache_entries WHERE namespace = ?",
(self._namespace,),
)

def child(self, name: str) -> Cache:
"""Create a child cache with the given name."""
namespace = f"{self._namespace}/{name}" if self._namespace else name
return SQLiteCache(
storage=self._storage,
database_name=self._database_name,
namespace=namespace,
)
6 changes: 3 additions & 3 deletions packages/graphrag-cache/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "graphrag-cache"
version = "3.1.2"
version = "3.2.0"
description = "GraphRAG cache package."
authors = [
{name = "Alonso Guevara Fernández", email = "[email protected]"},
Expand Down Expand Up @@ -31,8 +31,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"graphrag-common==3.1.2",
"graphrag-storage==3.1.2",
"graphrag-common==3.2.0",
"graphrag-storage==3.2.0",
]

[project.urls]
Expand Down
4 changes: 2 additions & 2 deletions packages/graphrag-chunking/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "graphrag-chunking"
version = "3.1.2"
version = "3.2.0"
description = "Chunking utilities for GraphRAG"
authors = [
{name = "Alonso Guevara Fernández", email = "[email protected]"},
Expand Down Expand Up @@ -30,7 +30,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"graphrag-common==3.1.2",
"graphrag-common==3.2.0",
"pydantic~=2.13",
]

Expand Down
2 changes: 1 addition & 1 deletion packages/graphrag-common/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "graphrag-common"
version = "3.1.2"
version = "3.2.0"
description = "Common utilities and types for GraphRAG"
authors = [
{name = "Alonso Guevara Fernández", email = "[email protected]"},
Expand Down
6 changes: 3 additions & 3 deletions packages/graphrag-input/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "graphrag-input"
version = "3.1.2"
version = "3.2.0"
description = "Input document loading utilities for GraphRAG"
authors = [
{name = "Alonso Guevara Fernández", email = "[email protected]"},
Expand Down Expand Up @@ -30,8 +30,8 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"graphrag-common==3.1.2",
"graphrag-storage==3.1.2",
"graphrag-common==3.2.0",
"graphrag-storage==3.2.0",
"pydantic~=2.13",
"markitdown[pdf]~=0.1.7",
"pyarrow>=14.0.0"
Expand Down
Loading
Loading