Files
revng-revng/python/revng/pypeline/storage/local_provider.py
Giacomo Vercesi aa28975c3f LocalStorageProvider: prune on startup
Add two situations where the cache is pruned on startup, removing all
the cached objects from storage:
1. the version field does not match the current one
2. the model has been changed out of band

The second one is very important as it allows to use the revng2 CLI
while editing the model manually on disk.
2026-04-10 11:45:13 +02:00

805 lines
32 KiB
Python

#
# This file is distributed under the MIT License. See LICENSE.md for details.
#
from __future__ import annotations
import os
import shutil
import sqlite3
from collections import defaultdict
from collections.abc import Buffer
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import AsyncGenerator, Collection, Mapping
from uuid import uuid4
import yaml
from revng.pypeline import __version__ as version
from revng.pypeline.container import ConfigurationId
from revng.pypeline.model import Model, ModelPathSet
from revng.pypeline.object import ObjectID
from revng.pypeline.task.pipe import ObjectDependencies, PipeCustomInvalidation
from revng.pypeline.utils import Locked, crypto_hash
from revng.pypeline.utils.logger import pypeline_logger
from revng.pypeline.utils.registry import get_singleton
from .file_provider import FileRequest
from .storage_provider import ContainerLocation, FileStorageEntry, InvalidatedObjects
from .storage_provider import ObjectsToInvalidate, ProjectID, ProjectMetadata, SavePointsRange
from .storage_provider import StorageProvider, StorageProviderFactory
from .util import _OBJECTID_MAXSIZE, check_kind_structure, check_object_id_supported_by_sql
from .util import compute_hash
# This is a binary mask that will be used for invalidation, thanks to the
# binary structure of ObjectID, all children are guaranteed to have the parent
# prefixed, so, to check for all children the check will be
# target_object_id <= checked_object_id <= CONCAT(target_object_id, _OBJECTID_MASK) # noqa: E800
_OBJECTID_MASK = f"x'{"ff" * _OBJECTID_MAXSIZE}'"
CREATE_TABLES = """
CREATE TABLE IF NOT EXISTS project(
id TEXT PRIMARY KEY CHECK (id = 0),
last_change REAL,
epoch INT NOT NULL,
version TEXT,
model_hash TEXT,
model_mtime REAL
) STRICT;
INSERT OR IGNORE INTO project (id, epoch) VALUES (0, 0);
CREATE TABLE IF NOT EXISTS objects(
savepoint_id INT NOT NULL,
container_id TEXT NOT NULL,
configuration_hash TEXT NOT NULL,
object_id BLOB NOT NULL,
content BLOB NOT NULL,
PRIMARY KEY (savepoint_id, container_id, configuration_hash, object_id)
) STRICT;
CREATE INDEX IF NOT EXISTS savepoint_id_on_object ON objects(savepoint_id);
CREATE INDEX IF NOT EXISTS container_id_on_object ON objects(container_id);
CREATE INDEX IF NOT EXISTS configuration_hash_on_object ON objects(configuration_hash);
CREATE INDEX IF NOT EXISTS object_id_on_object ON objects(object_id);
CREATE TABLE IF NOT EXISTS dependencies(
savepoint_id_start INT NOT NULL,
savepoint_id_end INT NOT NULL,
container_id TEXT NOT NULL,
configuration_hash TEXT NOT NULL,
object_id BLOB NOT NULL,
model_path TEXT NOT NULL,
PRIMARY KEY (savepoint_id_start, savepoint_id_end, container_id,
configuration_hash, object_id, model_path)
) STRICT;
CREATE INDEX IF NOT EXISTS model_path_on_dependencies ON dependencies(model_path);
CREATE TABLE IF NOT EXISTS custom_dependencies(
pipe_id INT NOT NULL,
configuration_hash TEXT NOT NULL,
argument_index INT NOT NULL,
object_id BLOB NOT NULL,
data BLOB NOT NULL,
PRIMARY KEY (pipe_id, configuration_hash)
) STRICT;
CREATE INDEX IF NOT EXISTS custom_dependencies_index
ON custom_dependencies(pipe_id, configuration_hash);
"""
CREATE_TABLE_INVALIDATE = """
CREATE TEMPORARY TABLE model_paths_{uuid}(
path TEXT NOT NULL
) STRICT;
CREATE TEMPORARY TABLE additional_objects_{uuid}(
savepoint_id_start INT NOT NULL,
savepoint_id_end INT NOT NULL,
container_id TEXT NOT NULL,
configuration_hash TEXT NOT NULL,
object_id BLOB NOT NULL,
PRIMARY KEY (savepoint_id_start, savepoint_id_end, container_id,
configuration_hash, object_id)
) STRICT;
CREATE TEMPORARY TABLE invalidated_objects_{uuid}(
object_row_id INT NOT NULL
) STRICT;
"""
HAS_QUERY = """
SELECT object_id FROM objects
WHERE
savepoint_id = ?
AND container_id = ?
AND configuration_hash = ?
AND object_id IN ({id_list})"""
GET_QUERY = """
SELECT object_id, content FROM objects
WHERE
savepoint_id = ?
AND container_id = ?
AND configuration_hash = ?
AND object_id IN ({id_list})"""
PUT_QUERY = """
REPLACE INTO objects(savepoint_id, container_id, configuration_hash, object_id, content)
VALUES (?, ?, ?, ?, ?)
"""
PUT_DEPENDENCIES_QUERY = "REPLACE INTO dependencies VALUES (?, ?, ?, ?, ?, ?)"
# Invalidation queries, these takes care, given populated tables `model_paths`
# and `additional_objects`, to remove all the applicable entries from the DB.
# The structure is such that only the invalidation data is returned to python,
# everything else lives exclusively on the DB; this saves up in round-trips.
# Invalidation happens with 3 queries:
# 1. Match the entries from the `objects` table that need to be removed (see
# below), store their `rowid` in the `invalidated_objects` temporary table
# 2. Delete from the `dependencies` table all the entries that have been
# generated by objects from (1). Since we already recorded which objects we
# want to delete the entries from this table are no longer needed.
# 3. Actually delete the entries from the `objects` table and return the list
# to python. This is in a separate variable due to technical reasons.
#
# The logic that decides, given a set of `model_paths` and
# `additional_objects`, which entries from `objects` will be delete is as
# follows:
# 1. Have the savepoint be in the savepoint range of the dependency
# 2. Have the configuration_hash match
# 3. Have the object_id be related to the dependency, this means that, either:
# 1. The objects.object_id is the same or a child of dependencies.object_id
# This is simplified by the structure of ObjectID, so this becomes a
# range comparison
# `dependencies.object_id <= objects.object_id <= (dependencies.object_id + mask)`
# 2. The object.object_id is a parent of dependencies.object_id. In the
# general case this would require generating the query dynamically. For
# the time being this is specialized for the current structure where
# there's only children of root, so the check becomes:
# `dependencies.object_id != x'' AND objects.object_id = x''`
INVALIDATE_QUERY = """
INSERT INTO invalidated_objects_{uuid}
SELECT DISTINCT objects.rowid
FROM objects
JOIN (
SELECT savepoint_id_start, savepoint_id_end, configuration_hash, object_id
FROM dependencies
WHERE dependencies.model_path IN (SELECT path FROM model_paths_{uuid})
UNION
SELECT savepoint_id_start, savepoint_id_end, configuration_hash, object_id
FROM additional_objects_{uuid}
) AS dependencies
WHERE (
(
objects.object_id >= dependencies.object_id
AND objects.object_id <= CAST((dependencies.object_id || {objectid_mask}) AS BLOB)
) OR (
dependencies.object_id != x'' AND objects.object_id = x''
)
)
AND objects.configuration_hash = dependencies.configuration_hash
AND objects.savepoint_id >= dependencies.savepoint_id_start
AND objects.savepoint_id <= dependencies.savepoint_id_end
;
DELETE FROM dependencies
WHERE rowid in (
SELECT DISTINCT dependencies.rowid
FROM dependencies
JOIN (
SELECT savepoint_id, container_id, configuration_hash, object_id
FROM objects
WHERE rowid IN (SELECT object_row_id FROM invalidated_objects_{uuid})
) AS invalidated_objects
WHERE
dependencies.savepoint_id_start = invalidated_objects.savepoint_id
AND dependencies.container_id = invalidated_objects.container_id
AND dependencies.configuration_hash = invalidated_objects.configuration_hash
AND dependencies.object_id = invalidated_objects.object_id
);
"""
# Second part of `INVALIDATE_QUERY`, this is a separate variable due to a
# limitation of the `sqlite3` module that does not return result rows when
# `executescript` is used
INVALIDATE_QUERY_2 = """
DELETE FROM objects
WHERE rowid IN (SELECT object_row_id FROM invalidated_objects_{uuid})
RETURNING object_id, container_id, savepoint_id, configuration_hash;
"""
class CursorWrapper:
def __init__(self, connection: sqlite3.Connection):
self.connection = connection
self.cursor: sqlite3.Cursor | None = None
def __enter__(self) -> sqlite3.Cursor:
assert self.cursor is None
self.cursor = self.connection.cursor()
return self.cursor
def __exit__(self, exc_type, exc_val, exc_tb):
assert self.cursor is not None
if exc_type is not None:
self.connection.rollback()
else:
self.connection.commit()
self.cursor.close()
return False
class LocalStorageProviderFactory(StorageProviderFactory):
def __init__(self, url: str):
assert url == "local://" or "local://?inline"
# TODO: use urlparse if more options are introduced
self.inline = url == "local://?inline"
self.providers: Locked[dict[ProjectID | None, Locked[LocalStorageProvider]]] = Locked({})
@classmethod
def scheme(cls) -> str:
return "local"
def _create_provider(
self,
base_directory: Path,
project_id: ProjectID | None,
token: str | None,
cache_dir: str,
) -> LocalStorageProvider:
# Figure out how the model should be name
model_type = get_singleton(Model) # type: ignore [type-abstract]
model_name = model_type.model_name()
# Find the model in the current directory or any of its parents
directory = base_directory
while True:
pypeline_logger.debug_log(f'Searching for model at "{directory / model_name}"')
if (directory / model_name).exists():
break
if directory == directory.parent:
raise FileNotFoundError(f'Model "{model_name}" not found')
directory = directory.parent
model_path = directory / model_name
pypeline_logger.debug_log(f'Model "{model_name}" found at "{model_path}"')
# Compute the hash of the model path as a tentative unique identifier for the project
# TODO: we are relying on the *absolute* model path, which means that if the
# user moves the project around, it will be treated as a different project
# and caches will be recomputed, and most importantly, if someone deletes
# the project and creates a new one at the same path, it will reuse the
# old cache.
if self.inline:
cache_path = (directory / ".cache").resolve()
cache_path.mkdir(parents=True, exist_ok=True)
db_path = cache_path / "data.sqlite"
else:
cache_path = Path(cache_dir)
db_name = crypto_hash(str(model_path)) + ".sqlite"
db_path = Path(cache_dir) / db_name
pypeline_logger.debug_log(f'Using DB "{db_path}"')
return LocalStorageProvider(db_path, model_path, cache_path)
@asynccontextmanager
async def get(
self,
base_directory: Path,
project_id: ProjectID | None,
token: str | None,
cache_dir: str | None,
) -> AsyncGenerator[StorageProvider]:
assert cache_dir is not None, "Cache directory must be provided"
# Get or create the provider for the given project ID
async with self.providers() as providers:
project_provider: Locked[LocalStorageProvider] | None = providers.get(project_id)
# If the provider is not found, create a new one and put it in a lock
if project_provider is None:
project_provider = Locked(
self._create_provider(
base_directory=base_directory,
project_id=project_id,
token=token,
cache_dir=cache_dir,
)
)
providers[project_id] = project_provider
# Release the global lock and acquire the project-specific one so other
# projects can proceed in parallel
async with project_provider() as provider:
yield provider
TemporaryProviderTuple = tuple["LocalStorageProvider", TemporaryDirectory]
class TemporaryLocalStorageProviderFactory(StorageProviderFactory):
def __init__(self, url: str):
assert url == "temporary://"
self.providers: Locked[dict[ProjectID | None, Locked[TemporaryProviderTuple]]] = Locked({})
@classmethod
def scheme(cls) -> str:
return "temporary"
@asynccontextmanager
async def get(
self,
base_directory: Path,
project_id: ProjectID | None,
token: str | None,
cache_dir: str | None,
) -> AsyncGenerator[StorageProvider]:
model_type = get_singleton(Model) # type: ignore [type-abstract]
model_name = model_type.model_name()
# Get or create the provider for the given project ID
async with self.providers() as providers:
project_provider: Locked[TemporaryProviderTuple] | None = providers.get(project_id)
# If the provider is not found, create a new one and put it in a lock
if project_provider is None:
temporary_dir = TemporaryDirectory()
temp_dir_path = Path(temporary_dir.name)
(temp_dir_path / model_name).touch()
(temp_dir_path / "cache").mkdir()
storage_provider = LocalStorageProvider(
db_path=temp_dir_path / "db.sqlite",
model_path=temp_dir_path / model_name,
cache_dir=temp_dir_path / "cache",
)
project_provider = Locked((storage_provider, temporary_dir))
providers[project_id] = project_provider
# Release the global lock and acquire the project-specific one so other
# projects can proceed in parallel
async with project_provider() as provider:
yield provider[0]
class LocalStorageProvider(StorageProvider):
"""StorageProvider implementation with backing sqlite3 db"""
def __init__(self, db_path: str | Path, model_path: Path, cache_dir: Path):
check_kind_structure()
self._model_path = model_path
self._model_directory = self._model_path.parent.resolve()
self._cache_dir = cache_dir
self._model_type = get_singleton(Model) # type: ignore[type-abstract]
self._connection = sqlite3.connect(db_path, autocommit=False)
self._init_tables()
self.epoch = self._get_epoch()
self._check_version()
self._check_model()
def _cursor(self) -> CursorWrapper:
return CursorWrapper(self._connection)
def _init_tables(self):
with self._cursor() as cursor:
cursor.executescript(CREATE_TABLES)
def _check_version(self):
with self._cursor() as cursor:
cursor.execute("SELECT version FROM project WHERE id is 0")
db_version = cursor.fetchone()[0]
if db_version is not None and db_version != version:
self.prune_objects()
# We either never wrote the version field or we just pruned all objects
# because of a version mismatch. In both cases write the current
# version string.
if db_version is None or db_version != version:
with self._cursor() as cursor:
cursor.execute("UPDATE project SET version = ? WHERE id is 0", (version,))
def _check_model(self):
with self._cursor() as cursor:
cursor.execute("SELECT model_hash, model_mtime FROM project WHERE id is 0")
model_metadata = cursor.fetchone()
model_mtime = self._model_path.stat().st_mtime
# We did not previously write the model metadata, write it
if model_metadata[0] is None:
model_hash = compute_hash(self._model_path)
with self._cursor() as cursor:
cursor.execute(
"UPDATE project SET model_hash = ?, model_mtime = ? WHERE id is 0",
(model_hash, model_mtime),
)
return
# Check if we actually got a non-null result, if so check that the
# mtime and hash of the model match (in this order for speed), if
# not prune
# WARNING: in some cases we might miss a model change if the model
# contents have changed but the mtime hasn't. In the future we might
# introduce extra checks (e.g. size) to alleviate this.
if model_metadata[1] == model_mtime:
return
# The mtime changed, check if the hash matches or not
model_hash = compute_hash(self._model_path)
if model_hash != model_metadata[0]:
self.prune_objects()
with self._cursor() as cursor:
cursor.execute(
"UPDATE project SET model_hash = ?, model_mtime = ? WHERE id is 0",
(model_hash, model_mtime),
)
self.epoch += 1
self._write_metadata(cursor)
else:
# mtime has changed but the content hasn't, update the mtime to
# the new one
with self._cursor() as cursor:
cursor.execute(
"UPDATE project SET model_mtime = ? WHERE id is 0",
(model_mtime,),
)
def _write_metadata(self, cursor: sqlite3.Cursor):
cursor.execute(
"UPDATE project SET last_change = ?, epoch = ? WHERE id is 0",
(datetime.now().timestamp(), self.epoch),
)
def _get_epoch(self) -> int:
# Try to get the epoch from the DB
with self._cursor() as cursor:
cursor.execute("SELECT epoch FROM project WHERE id is 0")
return cursor.fetchone()[0]
def has(
self,
location: ContainerLocation,
keys: Collection[ObjectID],
) -> list[ObjectID]:
if len(keys) == 0:
return []
# NOTE: possible SQL injection, sqlite has a limit on parameters. If it
# can't be avoided chunk selects by 999 values
id_list = ",".join([f"x'{key.to_bytes().hex()}'" for key in keys])
with self._cursor() as cursor:
cursor.execute(
HAS_QUERY.format(id_list=id_list),
(location.savepoint_id, location.container_id, location.configuration_id),
)
result = cursor.fetchall()
obj_id_type: type[ObjectID] = get_singleton(ObjectID) # type: ignore[type-abstract]
return [obj_id_type.from_bytes(x[0]) for x in result]
def get(
self,
location: ContainerLocation,
keys: Collection[ObjectID],
) -> dict[ObjectID, bytes]:
if len(keys) == 0:
return {}
# NOTE: possible SQL injection, sqlite has a limit on parameters. If it
# can't be avoided chunk selects by 999 values
id_list = ",".join([f"x'{key.to_bytes().hex()}'" for key in keys])
with self._cursor() as cursor:
cursor.execute(
GET_QUERY.format(id_list=id_list),
(location.savepoint_id, location.container_id, location.configuration_id),
)
result = cursor.fetchall()
obj_id_type: type[ObjectID] = get_singleton(ObjectID) # type: ignore[type-abstract]
return {obj_id_type.from_bytes(x[0]): x[1] for x in result}
def add_dependencies(
self,
savepoint_range: SavePointsRange,
configuration_id: ConfigurationId,
deps: ObjectDependencies,
) -> None:
with self._cursor() as cursor:
for container_id, object_id, model_path in deps:
check_object_id_supported_by_sql(object_id)
cursor.execute(
PUT_DEPENDENCIES_QUERY,
(
savepoint_range.start,
savepoint_range.end,
container_id,
configuration_id,
object_id.to_bytes(),
model_path,
),
)
self._write_metadata(cursor)
def put(
self,
location: ContainerLocation,
values: Mapping[ObjectID, Buffer],
) -> None:
with self._cursor() as cursor:
for object_id, content in values.items():
cursor.execute(
PUT_QUERY,
(
location.savepoint_id,
location.container_id,
location.configuration_id,
object_id.to_bytes(),
bytes(content),
),
)
self._write_metadata(cursor)
def invalidate(
self, invalidation_list: ModelPathSet, additional_objects: list[ObjectsToInvalidate]
) -> InvalidatedObjects:
if len(invalidation_list) == 0 and len(additional_objects) == 0:
return {}
object_id_type: type[ObjectID] = get_singleton(ObjectID) # type: ignore[type-abstract]
# Generate a random UUID, this will be needed to create a unique temporary table
table_uuid = uuid4().hex
# The result that will be returned by this function
invalidated: InvalidatedObjects = defaultdict(set)
with self._cursor() as cursor:
# Create the required tables for invalidations, all prefixed with
# UUID, these are:
# * model_paths_{UUID}: input `model_paths`
# * additional_objects_{UUID}: input `additional_objects`
# * invalidated_objects_{UUID}: the list of rowids that have been
# invalidated. This is used internally by the queries.
cursor.executescript(CREATE_TABLE_INVALIDATE.format(uuid=table_uuid))
# Pour our input data into the temporary tables
for path in invalidation_list:
cursor.execute(f"REPLACE INTO model_paths_{table_uuid} VALUES (?)", (path,))
for object_set in additional_objects:
for object_ in object_set.objects:
cursor.execute(
f"REPLACE INTO additional_objects_{table_uuid} VALUES (?, ?, ?, ?, ?)",
(
object_set.savepoint_range.start,
object_set.savepoint_range.end,
object_set.container_id,
object_set.configuration_id,
object_.to_bytes(),
),
)
# Run the actual invalidation (see comment above the
# `INVALIDATE_QUERY` for an explanation of how it selects the
# objects to remove)
cursor.executescript(
INVALIDATE_QUERY.format(objectid_mask=_OBJECTID_MASK, uuid=table_uuid)
)
# Due to a limitation of sqlite3 only `execute` returns rows,
# whereas executescript does not
cursor.execute(INVALIDATE_QUERY_2.format(uuid=table_uuid))
# Read the returned rows (the invalidated objects) and insert them
# into the `invalidated` dictionary
for row in cursor:
object_id = row[0]
location = ContainerLocation(
container_id=row[1],
savepoint_id=row[2],
configuration_id=row[3],
)
invalidated[location].add(object_id_type.from_bytes(object_id))
# DROP the temporary tables as they are no longer needed
cursor.execute(f"DROP TABLE additional_objects_{table_uuid};")
cursor.execute(f"DROP TABLE model_paths_{table_uuid};")
cursor.execute(f"DROP TABLE invalidated_objects_{table_uuid};")
# Write the last_change field
self._write_metadata(cursor)
return dict(invalidated)
def prune_objects(self):
with self._cursor() as cursor:
cursor.execute("DELETE FROM objects")
cursor.execute("DELETE FROM dependencies")
cursor.execute("DELETE FROM custom_dependencies")
self._write_metadata(cursor)
def get_epoch(self) -> int:
return self.epoch
def get_model(self) -> tuple[Model, int]:
model, changed = self._model_type.deserialize(self._model_path.read_bytes())
if changed:
self.prune_objects()
self._write_model(model)
return (model, self.epoch)
def set_model(self, new_model: Model) -> int:
# Check if the model was modified
current_model, _ = self._model_type.deserialize(self._model_path.read_bytes())
if current_model == new_model:
return self.epoch
# if so, write the new model and update the epoch
return self._write_model(new_model)
def _write_model(self, new_model: Model) -> int:
model_bytes = new_model.serialize()
self._model_path.write_bytes(model_bytes)
with self._cursor() as cursor:
cursor.execute(
"UPDATE project SET model_hash = ?, model_mtime = ? WHERE id is 0",
(compute_hash(model_bytes), self._model_path.stat().st_mtime),
)
self.epoch += 1
self._write_metadata(cursor)
return self.epoch
def metadata(self) -> ProjectMetadata:
with self._cursor() as cursor:
cursor.execute("SELECT last_change, version FROM project WHERE id is 0")
result = cursor.fetchone()
return ProjectMetadata(
last_change=datetime.fromtimestamp(result[0], timezone.utc),
version=result[1],
)
def put_files_in_storage(self, files: list[FileStorageEntry]) -> list[str]:
result = []
for file in files:
if file.contents is not None:
file_path = self._model_directory / file.name
file_path.write_bytes(file.contents)
hash_ = compute_hash(file.contents)
elif file.path is not None:
if file.path.parent.resolve() != self._model_directory:
# If here, the file is not in the directory where the model
# is present, copy it there
file_path = self._model_directory / file.path.name
shutil.copy2(file.path, file_path)
else:
file_path = file.path
hash_ = compute_hash(file_path)
self._write_link_file(file_path, hash_)
result.append(hash_)
return result
def get_files_from_storage(self, requests: list[FileRequest]) -> dict[str, bytes]:
return {r.hash: self._find_file(r).read_bytes() for r in requests}
def _write_link_file(self, path: Path, hash_: str):
link_file = self._cache_dir / f"resources/{hash_}.link"
link_file.parent.mkdir(parents=True, exist_ok=True)
data = {}
if link_file.is_file():
data = yaml.safe_load(link_file.read_text())
paths = {Path(p) for p in data.get("PathHints", [])}
paths.add(path.resolve())
paths.add(path.relative_to(self._model_directory, walk_up=True))
mtimes = [data.get("ModifiedTime", 0)]
for path_element in paths:
try:
stat = path_element.stat()
except (OSError, ValueError):
continue
mtimes.append(stat.st_mtime)
data = {"PathHints": [str(p) for p in paths], "ModifiedTime": max(mtimes)}
link_file.write_text(yaml.safe_dump(data))
def _find_file(self, request: FileRequest) -> Path:
link_file = self._cache_dir / f"resources/{request.hash}.link"
if link_file.is_file():
data = yaml.safe_load(link_file.read_text())
# Read the `PathHints` list, normalize the entries to all absolute paths
found_paths: list[Path] = []
for path in data["PathHints"]:
path_path = Path(path)
if not path_path.is_absolute():
path_path = (self._model_directory / path_path).resolve()
try:
if path_path.is_file():
found_paths.append(path_path)
except OSError:
pypeline_logger.debug_log(f"Skipping missing path {path_path!s}")
# Try and find a file from found_paths that matches the `ModifiedTime`
mtime = data["ModifiedTime"]
for path in found_paths:
if path.stat().st_mtime == mtime:
return path
# If here none of the found_paths matched mtime, try and update it
for path in found_paths:
if compute_hash(path) == request.hash:
data["ModifiedTime"] = path.stat().st_mtime
link_file.write_text(yaml.safe_dump(data))
return path
# If here, none of the paths in found_paths had a matching hash
# First check if there is a file in the model directory that matches
if request.name is not None:
maybe_file = self._model_directory / request.name
if self._compare_file(maybe_file, request):
self._write_link_file(maybe_file, request.hash)
return maybe_file
# If here, as a last resort, scan the entire model directory to try and
# find the file
with os.scandir(self._model_directory) as scan_iter:
for entry in scan_iter:
entry_path = Path(entry.path)
if entry.is_file() and self._compare_file(entry_path, request):
self._write_link_file(entry_path, request.hash)
return entry_path
# If here, no file has been found, throw an exception
raise ValueError("Could not find a suitable file")
@staticmethod
def _compare_file(path: Path, request: FileRequest) -> bool:
return (
path.is_file()
and (request.size is None or path.stat().st_size == request.size)
and compute_hash(path) == request.hash
)
def add_custom_invalidation_data(
self, pipe_id: int, configuration_hash: str, data: PipeCustomInvalidation
):
with self._cursor() as cursor:
for index, container_data in enumerate(data):
for object_id, invalidation_blob in container_data:
cursor.execute(
"REPLACE INTO custom_dependencies VALUES (?, ?, ?, ?, ?)",
(
pipe_id,
configuration_hash,
index,
object_id.to_bytes(),
bytes(invalidation_blob),
),
)
self._write_metadata(cursor)
def get_custom_invalidation_data(
self, pipe_id: int, configuration_hash: str
) -> PipeCustomInvalidation:
with self._cursor() as cursor:
cursor.execute(
"SELECT argument_index, object_id, data FROM custom_dependencies"
" WHERE pipe_id = ? AND configuration_hash = ?",
(pipe_id, configuration_hash),
)
sql_result = cursor.fetchall()
if len(sql_result) == 0:
return []
index_size = max(x[0] for x in sql_result)
obj_id_type: type[ObjectID] = get_singleton(ObjectID) # type: ignore[type-abstract]
result: list[list[tuple[ObjectID, bytes]]] = [[] for _ in range(index_size + 1)]
for argument_index, object_id, data in sql_result:
result[argument_index].append((obj_id_type.from_bytes(object_id), data))
return result