mirror of
https://github.com/revng/revng
synced 2026-06-21 14:07:57 +00:00
4f9e811110
Implement the Pypeline Data Viewer, an HTTP server that allows browsing the data stored by PRSS.
1008 lines
38 KiB
Python
1008 lines
38 KiB
Python
#
|
|
# This file is distributed under the MIT License. See LICENSE.md for details.
|
|
#
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import enum
|
|
import hashlib
|
|
import uuid
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import PurePath
|
|
from typing import Any, AsyncGenerator, Callable, cast
|
|
|
|
import psycopg.sql as sql
|
|
from psycopg import AsyncCursor, Connection
|
|
from psycopg.rows import DictRow, TupleRow, dict_row
|
|
from psycopg_pool import AsyncConnectionPool
|
|
|
|
from revng.pypeline.storage.util import _OBJECTID_MAXSIZE
|
|
from revng.pypeline.utils.db_migrator import DBMigrator
|
|
|
|
from .storage import AdditionalObjectEntry, CheckLockInvalid, CheckMetadataResult
|
|
from .storage import CustomDependency, CustomDependencyEntry, DependencyEntry, InvalidatedObject
|
|
from .storage import LockCheckType, LockRequest, ModelSetResult, ObjectEntry, ProjectMetadataRow
|
|
from .storage import RSSLockedProjectStorage, RSSProjectStorage, RSSStorage
|
|
|
|
# How many seconds before a lock is considered expired
|
|
LOCK_EXPIRY_SECONDS = 30
|
|
|
|
#
|
|
# Invalidation queries
|
|
# These queries are part of the invalidation workflow, which works in 5 steps:
|
|
# 1. Create temporary tables (`INVALIDATE_CREATE_TABLES`) to store the incoming
|
|
# data, all these tables are suffixed with a randomly-generated string
|
|
# `<suffix>` to allow the invalidation logic to run in parallel for multiple
|
|
# projects
|
|
# 2. Populate the tables
|
|
# 3. Run `INVALIDATE_FIND` which uses the data provided to find the objects
|
|
# that need to be deleted
|
|
# 4. Run `INVALIDATE_DELETE_DEPENDENCIES` which removes all the dependencies of
|
|
# the objects that are going to be deleted
|
|
# 5. Run `INVALIDATE_DELETE_OBJECTS` which actually deletes the rows from the
|
|
# `objects` table
|
|
#
|
|
|
|
# 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}'::bytea"
|
|
|
|
# This query creates the temporary tables needed for invalidation, these will
|
|
# hold the temporary invalidation data so that the queries below can be run
|
|
# with all the data in the database
|
|
INVALIDATE_CREATE_TABLES = """
|
|
CREATE TEMPORARY TABLE model_paths_{suffix}(
|
|
path TEXT NOT NULL
|
|
) ON COMMIT DROP;
|
|
|
|
CREATE TEMPORARY TABLE additional_objects_{suffix}(
|
|
savepoint_id_start INT NOT NULL,
|
|
savepoint_id_end INT NOT NULL,
|
|
container_id TEXT NOT NULL,
|
|
configuration_hash TEXT NOT NULL,
|
|
object_id BYTEA NOT NULL
|
|
) ON COMMIT DROP;
|
|
|
|
CREATE TEMPORARY TABLE invalidated_objects_{suffix}(
|
|
rowid tid NOT NULL
|
|
) ON COMMIT DROP;
|
|
"""
|
|
|
|
# This query looks in the `objects` table for any object that has been
|
|
# invalidated as a result of the data in `model_paths_<suffix>` and
|
|
# `additional_objects_<suffix>`. It stores the ctid of the rows in
|
|
# `invalidated_objects_<suffix>` so that the subsequent queries can go quickly
|
|
# without useless lookups.
|
|
INVALIDATE_FIND = """
|
|
INSERT INTO invalidated_objects_{suffix}
|
|
SELECT DISTINCT objects.ctid
|
|
FROM objects
|
|
JOIN (
|
|
SELECT savepoint_id_start, savepoint_id_end,
|
|
configuration_hash, object_id
|
|
FROM dependencies
|
|
JOIN model_paths_{suffix}
|
|
ON dependencies.model_path = model_paths_{suffix}.path
|
|
WHERE dependencies.project_id = %s
|
|
UNION
|
|
SELECT savepoint_id_start, savepoint_id_end,
|
|
configuration_hash, object_id
|
|
FROM additional_objects_{suffix}
|
|
) AS dependencies ON TRUE
|
|
WHERE objects.project_id = %s
|
|
AND ((
|
|
objects.object_id >= dependencies.object_id
|
|
AND objects.object_id <= (dependencies.object_id || {objectid_mask})
|
|
) OR (
|
|
dependencies.object_id != '' AND objects.object_id = ''
|
|
)
|
|
)
|
|
AND objects.configuration_hash = dependencies.configuration_hash
|
|
AND objects.savepoint_id >= dependencies.savepoint_id_start
|
|
AND objects.savepoint_id <= dependencies.savepoint_id_end
|
|
"""
|
|
|
|
# Delete all the rows in the `dependencies` table that were connected to
|
|
# invalidated objects
|
|
INVALIDATE_DELETE_DEPENDENCIES = """
|
|
DELETE FROM dependencies
|
|
WHERE dependencies.ctid IN (
|
|
SELECT DISTINCT dependencies.ctid FROM dependencies
|
|
JOIN (
|
|
SELECT savepoint_id, container_id, configuration_hash, object_id
|
|
FROM objects
|
|
WHERE ctid IN (SELECT rowid FROM invalidated_objects_{suffix})
|
|
) AS invalidated_objects ON TRUE
|
|
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
|
|
)
|
|
"""
|
|
|
|
# Actually delete the objects, return the identifier of the objects deleted
|
|
INVALIDATE_DELETE_OBJECTS = """
|
|
DELETE FROM objects
|
|
USING invalidated_objects_{suffix} AS inv
|
|
WHERE objects.ctid = inv.rowid
|
|
RETURNING objects.object_id_string, objects.container_id,
|
|
objects.savepoint_id, objects.configuration_hash
|
|
"""
|
|
|
|
|
|
class LockState(enum.IntEnum):
|
|
"""
|
|
The state that a lock can be in. The values are explicitly set since they
|
|
will be stored in the database.
|
|
"""
|
|
|
|
ARTIFACT_WAITING = 0
|
|
ARTIFACT = 1
|
|
ANALYSIS_WAITING = 2
|
|
ANALYSIS_READING = 3
|
|
ANALYSIS_PENDING_COMMIT = 4
|
|
ANALYSIS_COMMITTING = 5
|
|
|
|
|
|
class AnalysisState(enum.Enum):
|
|
NONE = enum.auto()
|
|
READING = enum.auto()
|
|
WRITING = enum.auto()
|
|
|
|
|
|
@dataclass
|
|
class ProjectState:
|
|
artifact_running: bool
|
|
analysis_state: AnalysisState
|
|
|
|
|
|
@dataclass
|
|
class LockOperationContext:
|
|
cursor: AsyncCursor
|
|
state: ProjectState
|
|
should_wait_for_lock: bool = False
|
|
|
|
|
|
class CheckLockResult(enum.Enum):
|
|
MISSING = enum.auto()
|
|
OK = enum.auto()
|
|
EXPIRED = enum.auto()
|
|
TYPE_MISMATCH = enum.auto()
|
|
|
|
|
|
def _join_lockstates(*values: LockState):
|
|
return sql.SQL(",").join((sql.Literal(int(x)) for x in values))
|
|
|
|
|
|
async def _fetch_one(cursor: AsyncCursor) -> TupleRow:
|
|
result = await cursor.fetchone()
|
|
assert result is not None
|
|
return result
|
|
|
|
|
|
async def _row_generator[T](cursor: AsyncCursor, transform: Callable[[TupleRow], T]) -> list[T]:
|
|
result: list[T] = []
|
|
async for row in cursor:
|
|
result.append(transform(row))
|
|
return result
|
|
|
|
|
|
class Migrator(DBMigrator):
|
|
def __init__(self, conninfo: str):
|
|
super().__init__(
|
|
"pypeline", PurePath(self.__module__.replace(".", "/")).parent / "postgres_migrations"
|
|
)
|
|
self._connection = Connection.connect(conninfo)
|
|
self._app_name = "rss_server"
|
|
|
|
def _create_tables_if_missing(self):
|
|
self._connection.execute(
|
|
"CREATE TABLE IF NOT EXISTS "
|
|
"migrations(app_name TEXT PRIMARY KEY, version INT NOT NULL)"
|
|
)
|
|
self._connection.commit()
|
|
|
|
def _get_last_migration(self) -> int:
|
|
result = self._connection.execute(
|
|
"SELECT version FROM migrations WHERE app_name = %s", (self._app_name,)
|
|
).fetchone()
|
|
return 0 if result is None else result[0]
|
|
|
|
def _apply_migration(self, version: int, body: str):
|
|
self._connection.execute(body.encode())
|
|
self._connection.execute(
|
|
"INSERT INTO migrations VALUES (%s, %s) "
|
|
"ON CONFLICT (app_name) DO UPDATE SET version = excluded.version",
|
|
(self._app_name, version),
|
|
)
|
|
self._connection.commit()
|
|
|
|
def truncate_locks(self):
|
|
self._connection.execute("TRUNCATE TABLE locks")
|
|
self._connection.commit()
|
|
|
|
|
|
class PostgresRSSStorage(RSSStorage):
|
|
"""RSSStorage backed by PostgreSQL via psycopg 3"""
|
|
|
|
def __init__(self, pool: AsyncConnectionPool, locked: bool):
|
|
self.pool = pool
|
|
self.locked = locked
|
|
|
|
@classmethod
|
|
async def make_locked(cls, connection_string: str) -> PostgresRSSStorage:
|
|
pool = AsyncConnectionPool(connection_string, min_size=0, max_size=1000, open=False)
|
|
await pool.open()
|
|
return cls(pool, True)
|
|
|
|
@classmethod
|
|
async def make_unlocked(cls, connection_string: str) -> PostgresRSSStorage:
|
|
pool = AsyncConnectionPool(connection_string, min_size=0, max_size=1000, open=False)
|
|
await pool.open()
|
|
return cls(pool, False)
|
|
|
|
async def close(self):
|
|
await self.pool.close()
|
|
|
|
@staticmethod
|
|
def migrate(connection_string: str):
|
|
migrator = Migrator(connection_string)
|
|
migrator.migrate()
|
|
migrator.truncate_locks()
|
|
|
|
@classmethod
|
|
def background_tasks(cls, connection_string: str):
|
|
async def cleanup():
|
|
instance = await cls.make_locked(connection_string)
|
|
return await instance._cleanup_locks()
|
|
|
|
return [asyncio.create_task(cleanup())]
|
|
|
|
@asynccontextmanager
|
|
async def _connection(self):
|
|
async with self.pool.connection() as conn:
|
|
yield conn
|
|
|
|
@asynccontextmanager
|
|
async def _cursor(self):
|
|
async with self._connection() as conn:
|
|
async with conn.transaction():
|
|
yield conn.cursor()
|
|
|
|
async def _cleanup_locks(self):
|
|
while True:
|
|
await asyncio.sleep(30)
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"DELETE FROM locks WHERE refresh_timestamp + make_interval(secs => %s) < NOW()"
|
|
" AND in_use is FALSE"
|
|
" RETURNING project_id, lock_id",
|
|
(LOCK_EXPIRY_SECONDS,),
|
|
)
|
|
|
|
already_unblocked_projects: set[str] = set()
|
|
async for project_id, lock_id in cursor:
|
|
if project_id in already_unblocked_projects:
|
|
continue
|
|
|
|
project_storage = self.get_project_storage(project_id)
|
|
async with project_storage._mutate_project_lock(None) as context:
|
|
await project_storage._unlock_next(context.cursor, context.state)
|
|
|
|
already_unblocked_projects.add(project_id)
|
|
|
|
def get_project_storage(self, project_id: str) -> PostgresRSSProjectStorage:
|
|
return PostgresRSSProjectStorage(self, project_id)
|
|
|
|
|
|
class PostgresRSSProjectStorage(RSSProjectStorage):
|
|
"""
|
|
This implements the `RSSProjectStorage` interface, allowing to interact
|
|
with a single project instance. This class mainly deals with locking and
|
|
managing metadata. For actual storage operation the lock must be held,
|
|
which happens via the use of `get_locked` to get an instance of
|
|
`RSSLockedProjectStorage`.
|
|
"""
|
|
|
|
def __init__(self, parent: PostgresRSSStorage, project_id: str):
|
|
self._parent = parent
|
|
self._project_id = project_id
|
|
|
|
def _connection(self):
|
|
return self._parent._connection()
|
|
|
|
def _cursor(self):
|
|
return self._parent._cursor()
|
|
|
|
#
|
|
# Locking endpoints
|
|
#
|
|
|
|
async def _compute_project_state(self, cursor: AsyncCursor) -> ProjectState:
|
|
await cursor.execute(
|
|
sql.SQL(
|
|
"SELECT lock_type FROM locks WHERE project_id = %s "
|
|
"AND lock_type IN ({values}) LIMIT 1"
|
|
).format(
|
|
values=_join_lockstates(
|
|
LockState.ANALYSIS_READING,
|
|
LockState.ANALYSIS_PENDING_COMMIT,
|
|
LockState.ANALYSIS_COMMITTING,
|
|
)
|
|
),
|
|
(self._project_id,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
analysis_state = AnalysisState.NONE
|
|
else:
|
|
lt = LockState(row[0])
|
|
if lt == LockState.ANALYSIS_READING:
|
|
analysis_state = AnalysisState.READING
|
|
else:
|
|
analysis_state = AnalysisState.WRITING
|
|
|
|
await cursor.execute(
|
|
"SELECT 1 FROM locks WHERE project_id = %s AND lock_type = %s LIMIT 1",
|
|
(self._project_id, int(LockState.ARTIFACT)),
|
|
)
|
|
artifact_running = (await cursor.fetchone()) is not None
|
|
|
|
return ProjectState(
|
|
artifact_running=artifact_running,
|
|
analysis_state=analysis_state,
|
|
)
|
|
|
|
@asynccontextmanager
|
|
async def _mutate_project_lock(self, lock_id: str | None):
|
|
# TODO: the pg_advisory_xact_lock is global w.r.t. the postgres
|
|
# instance, if more places need to use them then the bits used from the
|
|
# hash should be reduced (e.g. 60 bits) and the unused bits would be
|
|
# used to encode the "lock type"
|
|
digest = hashlib.sha256(self._project_id.encode()).digest()
|
|
project_key = int.from_bytes(digest[:8], "little", signed=True)
|
|
|
|
async with self._connection() as connection:
|
|
async with connection.transaction():
|
|
async with connection.cursor() as cursor:
|
|
await cursor.execute("SELECT pg_advisory_xact_lock(%s)", (project_key,))
|
|
# TODO: the project state is always computed but the `body`
|
|
# might not read it at all. In the future `ProjectState`
|
|
# could compute the data lazily.
|
|
state = await self._compute_project_state(cursor)
|
|
context = LockOperationContext(cursor, state)
|
|
yield context
|
|
if not context.should_wait_for_lock:
|
|
# We don't have to wait, return immediately
|
|
return
|
|
|
|
# If here there lock has to be waited, run `LISTEN` on the
|
|
# requested channel while the lock is still being held
|
|
assert lock_id is not None
|
|
channel = sql.Identifier(f"lock_{self._project_id}_{lock_id}")
|
|
await cursor.execute(sql.SQL("LISTEN {}").format(channel))
|
|
|
|
# When here the transaction has been committed, so the
|
|
# `pg_advisory_xact_lock` has been closed
|
|
async for _ in connection.notifies():
|
|
break
|
|
await connection.execute(sql.SQL("UNLISTEN {}").format(channel))
|
|
|
|
@asynccontextmanager
|
|
async def _upgrade_to_commiting_lock(self, lock_id: str):
|
|
"""
|
|
Temporarily upgrade an `ANALYSIS_READING` lock to `ANALYSIS_COMMITTING`
|
|
lock, waiting for pending `ARTIFACT` locks to finish before returning.
|
|
"""
|
|
|
|
async with self._mutate_project_lock(lock_id) as context:
|
|
if not context.state.artifact_running:
|
|
# If there aren't any artifact locks reading the model then the
|
|
# lock can be directly upgraded
|
|
lock_state = LockState.ANALYSIS_COMMITTING
|
|
else:
|
|
# If here there are artifact locks active. Set the lock to
|
|
# `ANALYSIS_PENDING_COMMIT` and wait to be promoted
|
|
lock_state = LockState.ANALYSIS_PENDING_COMMIT
|
|
context.should_wait_for_lock = True
|
|
|
|
await context.cursor.execute(
|
|
"UPDATE locks SET lock_type = %s WHERE project_id = %s AND lock_id = %s",
|
|
(int(lock_state), self._project_id, lock_id),
|
|
)
|
|
|
|
try:
|
|
yield None
|
|
finally:
|
|
async with self._mutate_project_lock(lock_id) as context:
|
|
# Downgrade the lock back to `ANALYSIS_READING`
|
|
await context.cursor.execute(
|
|
"UPDATE locks SET lock_type = %s WHERE project_id = %s AND lock_id = %s",
|
|
(int(LockState.ANALYSIS_READING), self._project_id, lock_id),
|
|
)
|
|
|
|
async def make_lock(self, lock_type: LockRequest) -> str:
|
|
lock_id = str(uuid.uuid4())
|
|
|
|
async with self._mutate_project_lock(lock_id) as context:
|
|
if lock_type == LockRequest.ARTIFACT:
|
|
if context.state.analysis_state != AnalysisState.WRITING:
|
|
lock_state = LockState.ARTIFACT
|
|
else:
|
|
lock_state = LockState.ARTIFACT_WAITING
|
|
context.should_wait_for_lock = True
|
|
|
|
elif lock_type == LockRequest.ANALYSIS:
|
|
if context.state.analysis_state == AnalysisState.NONE:
|
|
lock_state = LockState.ANALYSIS_READING
|
|
else:
|
|
lock_state = LockState.ANALYSIS_WAITING
|
|
context.should_wait_for_lock = True
|
|
|
|
else:
|
|
raise ValueError
|
|
|
|
await context.cursor.execute(
|
|
"INSERT INTO locks (project_id, lock_id, lock_type) VALUES (%s, %s, %s)",
|
|
(self._project_id, lock_id, int(lock_state)),
|
|
)
|
|
|
|
return lock_id
|
|
|
|
async def _unlock_next(self, cursor: AsyncCursor, state: ProjectState):
|
|
pending_locks = await self._get_pending_locks(cursor, self._project_id)
|
|
|
|
to_promote: list[tuple[str, LockState]] = []
|
|
for lock_id, lock_state in pending_locks:
|
|
if state.analysis_state == AnalysisState.WRITING and state.artifact_running:
|
|
break
|
|
|
|
if lock_state == LockState.ARTIFACT_WAITING:
|
|
if state.analysis_state != AnalysisState.WRITING:
|
|
to_promote.append((lock_id, LockState.ARTIFACT))
|
|
state.artifact_running = True
|
|
|
|
elif lock_state == LockState.ANALYSIS_WAITING:
|
|
if state.analysis_state == AnalysisState.NONE:
|
|
to_promote.append((lock_id, LockState.ANALYSIS_READING))
|
|
state.analysis_state = AnalysisState.READING
|
|
|
|
elif lock_state == LockState.ANALYSIS_PENDING_COMMIT: # noqa: SIM102
|
|
if not state.artifact_running:
|
|
to_promote.append((lock_id, LockState.ANALYSIS_COMMITTING))
|
|
state.analysis_state = AnalysisState.WRITING
|
|
|
|
for lock_id, new_state in to_promote:
|
|
await cursor.execute(
|
|
"UPDATE locks SET lock_type = %s WHERE project_id = %s AND lock_id = %s",
|
|
(int(new_state), self._project_id, lock_id),
|
|
)
|
|
await cursor.execute(
|
|
sql.SQL("NOTIFY {}").format(sql.Identifier(f"lock_{self._project_id}_{lock_id}"))
|
|
)
|
|
|
|
async def release_lock(self, lock_id: str):
|
|
async with self._mutate_project_lock(lock_id) as context:
|
|
await context.cursor.execute(
|
|
"SELECT in_use FROM locks WHERE project_id = %s AND lock_id = %s",
|
|
(self._project_id, lock_id),
|
|
)
|
|
result = await context.cursor.fetchone()
|
|
if result is None:
|
|
raise ValueError("Missing lock")
|
|
if result[0]:
|
|
raise ValueError("Lock is in use")
|
|
|
|
await context.cursor.execute(
|
|
"DELETE FROM locks WHERE project_id = %s AND lock_id = %s",
|
|
(self._project_id, lock_id),
|
|
)
|
|
await self._unlock_next(context.cursor, context.state)
|
|
|
|
async def _is_lock_compatible(
|
|
self, cursor: AsyncCursor, project_id: str, lock_id: str, type_: LockCheckType
|
|
) -> CheckLockResult:
|
|
if type_ == LockCheckType.NONE:
|
|
return CheckLockResult.OK
|
|
|
|
await cursor.execute(
|
|
"SELECT refresh_timestamp + make_interval(secs => %s) > NOW(), lock_type"
|
|
" FROM locks WHERE project_id = %s AND lock_id = %s",
|
|
(LOCK_EXPIRY_SECONDS, project_id, lock_id),
|
|
)
|
|
result = await cursor.fetchone()
|
|
if result is None:
|
|
return CheckLockResult.MISSING
|
|
if not result[0]:
|
|
return CheckLockResult.EXPIRED
|
|
|
|
actual_lock_type = result[1]
|
|
if type_ == LockCheckType.ANY:
|
|
return CheckLockResult.OK
|
|
elif type_ == LockCheckType.ARTIFACT:
|
|
ok = actual_lock_type in (int(LockState.ARTIFACT), int(LockState.ANALYSIS_READING))
|
|
elif type_ == LockCheckType.ANALYSIS:
|
|
ok = actual_lock_type == int(LockState.ANALYSIS_READING)
|
|
|
|
return CheckLockResult.OK if ok else CheckLockResult.TYPE_MISMATCH
|
|
|
|
async def renew_lock(self, lock_id: str):
|
|
async with self._cursor() as cursor:
|
|
state = await self._is_lock_compatible(
|
|
cursor, self._project_id, lock_id, LockCheckType.ANY
|
|
)
|
|
if state == CheckLockResult.MISSING:
|
|
return False
|
|
elif state == CheckLockResult.EXPIRED:
|
|
await self.release_lock(lock_id)
|
|
return False
|
|
|
|
await cursor.execute(
|
|
"UPDATE locks SET refresh_timestamp = NOW() WHERE project_id = %s AND lock_id = %s",
|
|
(self._project_id, lock_id),
|
|
)
|
|
return True
|
|
|
|
async def _get_pending_locks(
|
|
self, cursor: AsyncCursor, project_id: str
|
|
) -> list[tuple[str, LockState]]:
|
|
query = sql.SQL(
|
|
"SELECT lock_id, lock_type FROM locks WHERE "
|
|
"project_id = %s AND lock_type IN ({values}) ORDER BY creation_timestamp ASC"
|
|
).format(
|
|
values=_join_lockstates(
|
|
LockState.ARTIFACT_WAITING,
|
|
LockState.ANALYSIS_WAITING,
|
|
LockState.ANALYSIS_PENDING_COMMIT,
|
|
)
|
|
)
|
|
|
|
await cursor.execute(query, (project_id,))
|
|
return await _row_generator(cursor, lambda r: (r[0], LockState(r[1])))
|
|
|
|
@asynccontextmanager
|
|
async def get_locked(
|
|
self, lock_id: str, initial_type: LockCheckType
|
|
) -> AsyncGenerator[PostgresRSSLockedProjectStorage]:
|
|
# If the PostgresRSSStorage instance was created via `make_unlocked`
|
|
# then no lock has to be acquired (it is assumed that the user will
|
|
# only read the DB), just return the instance
|
|
if not self._parent.locked:
|
|
async with self._cursor() as cursor:
|
|
yield PostgresRSSLockedProjectStorage(self, cursor, self._project_id, lock_id)
|
|
return
|
|
|
|
# A raw connection (as opposed to a cursor with a transaction) needs to
|
|
# be acquired because this function manages commits manually
|
|
async with self._connection() as connection:
|
|
# Actually check that the lock is valid in the DB w.r.t. the type
|
|
# provided
|
|
async with connection.cursor() as cursor:
|
|
check_lock_result = await self._is_lock_compatible(
|
|
cursor, self._project_id, lock_id, initial_type
|
|
)
|
|
|
|
# Since this is a context manager the only way to exit is to throw
|
|
# an exception in case the lock turned out to be invalid.
|
|
if check_lock_result != CheckLockResult.OK:
|
|
raise CheckLockInvalid
|
|
|
|
# Set the `in_use` flag of the lock, this prevents it from being deleted
|
|
await connection.execute(
|
|
"UPDATE locks SET in_use = TRUE WHERE project_id = %s AND lock_id = %s",
|
|
(self._project_id, lock_id),
|
|
)
|
|
await connection.commit()
|
|
|
|
# Yield, finishing the enter portion of the context manager
|
|
try:
|
|
async with connection.transaction():
|
|
async with connection.cursor() as cursor:
|
|
yield PostgresRSSLockedProjectStorage(
|
|
self, cursor, self._project_id, lock_id
|
|
)
|
|
finally:
|
|
# Here the context manager is exiting, unset the `in_use` flag,
|
|
# so that the lock can be deleted if needed
|
|
await connection.execute(
|
|
"UPDATE locks SET in_use = FALSE WHERE project_id = %s AND lock_id = %s",
|
|
(self._project_id, lock_id),
|
|
)
|
|
await connection.commit()
|
|
|
|
#
|
|
# Metadata
|
|
#
|
|
|
|
async def get_metadata(self) -> ProjectMetadataRow:
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT pipeline_description_hash, version, "
|
|
"extract(epoch FROM last_fetch)::float AS last_fetch, "
|
|
"extract(epoch FROM last_object_save)::float AS last_object_save, "
|
|
"extract(epoch FROM last_model_save)::float AS last_model_save "
|
|
"FROM project WHERE project_id = %s",
|
|
(self._project_id,),
|
|
)
|
|
|
|
cursor.row_factory = cast(Any, dict_row)
|
|
row = cast(DictRow, await _fetch_one(cursor))
|
|
return cast(ProjectMetadataRow, row)
|
|
|
|
async def put_pipeline_description(self, hash_: str, content: bytes):
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"INSERT INTO pipeline_descriptions (hash, content) VALUES (%s, %s)"
|
|
"ON CONFLICT (hash) DO NOTHING",
|
|
(hash_, content),
|
|
)
|
|
|
|
async def get_pipeline_description(self):
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT content FROM pipeline_descriptions pd "
|
|
"JOIN project p ON p.pipeline_description_hash = pd.hash "
|
|
"WHERE p.project_id = %s",
|
|
(self._project_id,),
|
|
)
|
|
return (await _fetch_one(cursor))[0]
|
|
|
|
#
|
|
# Project lifecycle
|
|
#
|
|
|
|
async def create_project_if_missing(self):
|
|
"""
|
|
Create a project entry in the project table. If it already exists this
|
|
function does nothing.
|
|
"""
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"INSERT INTO project (project_id) VALUES (%s) ON CONFLICT DO NOTHING",
|
|
(self._project_id,),
|
|
)
|
|
|
|
async def upgrade_project(
|
|
self, version: str, pipeline_description_hash: str
|
|
) -> CheckMetadataResult:
|
|
async with self._cursor() as cursor:
|
|
await cursor.execute(
|
|
"SELECT version, pipeline_description_hash FROM project WHERE project_id = %s",
|
|
(self._project_id,),
|
|
)
|
|
result = await _fetch_one(cursor)
|
|
|
|
if result[0] == version and result[1] == pipeline_description_hash:
|
|
return CheckMetadataResult.OK
|
|
|
|
# Check that the pipeline description hash is present, otherwise do
|
|
# an early return
|
|
if result[1] != pipeline_description_hash:
|
|
await cursor.execute(
|
|
"SELECT 1 FROM pipeline_descriptions WHERE hash = %s",
|
|
(pipeline_description_hash,),
|
|
)
|
|
if (await cursor.fetchone()) is None:
|
|
return CheckMetadataResult.PIPELINE_DESCRIPTION_HASH_MISSING
|
|
|
|
# If here everything needs to be pruned, first obtain an
|
|
# analysis lock in the committing state
|
|
lock_id = await self.make_lock(LockRequest.ANALYSIS)
|
|
try:
|
|
async with self.get_locked(lock_id, LockCheckType.ANALYSIS) as locked_storage:
|
|
async with self._upgrade_to_commiting_lock(lock_id):
|
|
await locked_storage.prune_objects()
|
|
await cursor.execute(
|
|
"UPDATE project SET version = %s, pipeline_description_hash = %s, "
|
|
"last_object_save = NOW() WHERE project_id = %s",
|
|
(version, pipeline_description_hash, self._project_id),
|
|
)
|
|
finally:
|
|
await self.release_lock(lock_id)
|
|
|
|
return CheckMetadataResult.PRUNE_DONE
|
|
|
|
|
|
class PostgresRSSLockedProjectStorage(RSSLockedProjectStorage):
|
|
"""
|
|
This class is an implementation of `RSSLockedProjectStorage`, which is
|
|
guaranteed to have the lock being held as long as this class is used.
|
|
"""
|
|
|
|
def __init__(
|
|
self, parent: PostgresRSSProjectStorage, cursor: AsyncCursor, project_id: str, lock_id: str
|
|
):
|
|
self._parent = parent
|
|
self._cursor = cursor
|
|
self._project_id = project_id
|
|
self._lock_id = lock_id
|
|
|
|
#
|
|
# Pypeline-facing endpoints
|
|
#
|
|
|
|
async def has_objects(
|
|
self,
|
|
savepoint_id: int,
|
|
container_id: str,
|
|
configuration_hash: str,
|
|
object_ids: list[bytes],
|
|
) -> list[bytes]:
|
|
if len(object_ids) == 0:
|
|
return []
|
|
|
|
await self._cursor.execute(
|
|
"SELECT object_id FROM objects WHERE project_id = %s AND savepoint_id = %s "
|
|
"AND container_id = %s AND configuration_hash = %s AND object_id = ANY(%s)",
|
|
(self._project_id, savepoint_id, container_id, configuration_hash, object_ids),
|
|
)
|
|
return await _row_generator(self._cursor, lambda r: r[0])
|
|
|
|
async def get_objects(
|
|
self,
|
|
savepoint_id: int,
|
|
container_id: str,
|
|
configuration_hash: str,
|
|
object_ids: list[bytes],
|
|
) -> list[tuple[bytes, bytes]]:
|
|
if len(object_ids) == 0:
|
|
return []
|
|
|
|
await self._cursor.execute(
|
|
"SELECT object_id, content FROM objects "
|
|
"WHERE project_id = %s AND savepoint_id = %s AND container_id = %s "
|
|
"AND configuration_hash = %s AND object_id = ANY(%s)",
|
|
(self._project_id, savepoint_id, container_id, configuration_hash, object_ids),
|
|
)
|
|
result = await _row_generator(self._cursor, lambda r: (r[0], r[1]))
|
|
await self._cursor.execute(
|
|
"UPDATE project SET last_fetch = NOW() WHERE project_id = %s",
|
|
(self._project_id,),
|
|
)
|
|
return result
|
|
|
|
async def get_custom_invalidation_data(
|
|
self,
|
|
pipe_id: int,
|
|
configuration_hash: str,
|
|
) -> list[CustomDependencyEntry]:
|
|
await self._cursor.execute(
|
|
"SELECT argument_index, object_id, data FROM custom_dependencies "
|
|
"WHERE project_id = %s AND pipe_id = %s AND configuration_hash = %s",
|
|
(self._project_id, pipe_id, configuration_hash),
|
|
)
|
|
return await _row_generator(self._cursor, lambda r: CustomDependencyEntry(r[0], r[1], r[2]))
|
|
|
|
async def _run_invalidation(
|
|
self,
|
|
model_paths: list[str],
|
|
additional_objects: list[AdditionalObjectEntry],
|
|
) -> list[InvalidatedObject]:
|
|
if len(model_paths) == 0 and len(additional_objects) == 0:
|
|
return []
|
|
|
|
suffix = uuid.uuid4().hex
|
|
|
|
# Step 1, create the temporary tables
|
|
await self._cursor.execute(INVALIDATE_CREATE_TABLES.format(suffix=suffix).encode())
|
|
|
|
# Step 2, insert the incoming data
|
|
if len(model_paths) > 0:
|
|
await self._cursor.executemany(
|
|
f"INSERT INTO model_paths_{suffix} VALUES (%s)".encode(),
|
|
[(p,) for p in model_paths],
|
|
)
|
|
|
|
if len(additional_objects) > 0:
|
|
await self._cursor.executemany(
|
|
f"INSERT INTO additional_objects_{suffix} VALUES (%s, %s, %s, %s, %s)".encode(),
|
|
(
|
|
(
|
|
ao.savepoint_id_start,
|
|
ao.savepoint_id_end,
|
|
ao.container_id,
|
|
ao.configuration_hash,
|
|
ao.object_id,
|
|
)
|
|
for ao in additional_objects
|
|
),
|
|
)
|
|
|
|
# Step 3, find the objects to delete, store them into
|
|
# `invalidated_objects_<suffix>`
|
|
await self._cursor.execute(
|
|
INVALIDATE_FIND.format(suffix=suffix, objectid_mask=_OBJECTID_MASK).encode(),
|
|
(self._project_id, self._project_id),
|
|
)
|
|
|
|
# Step 4, delete the dependencies for all the objects that are going to
|
|
# be deleted
|
|
await self._cursor.execute(INVALIDATE_DELETE_DEPENDENCIES.format(suffix=suffix).encode())
|
|
|
|
# Step 5, actually delete the objects and return the invalidated data
|
|
await self._cursor.execute(INVALIDATE_DELETE_OBJECTS.format(suffix=suffix).encode())
|
|
return await _row_generator(
|
|
self._cursor,
|
|
lambda r: InvalidatedObject(
|
|
object_id=r[0],
|
|
container_id=r[1],
|
|
savepoint_id=r[2],
|
|
configuration_hash=r[3],
|
|
),
|
|
)
|
|
|
|
async def prune_objects(self):
|
|
await self._cursor.execute("DELETE FROM objects WHERE project_id = %s", (self._project_id,))
|
|
await self._cursor.execute(
|
|
"DELETE FROM dependencies WHERE project_id = %s", (self._project_id,)
|
|
)
|
|
await self._cursor.execute(
|
|
"DELETE FROM custom_dependencies WHERE project_id = %s", (self._project_id,)
|
|
)
|
|
|
|
async def get_epoch(self) -> int:
|
|
await self._cursor.execute(
|
|
"SELECT epoch FROM project WHERE project_id = %s", (self._project_id,)
|
|
)
|
|
row = await self._cursor.fetchone()
|
|
if row is None or row[0] is None:
|
|
return 0
|
|
else:
|
|
return row[0]
|
|
|
|
async def get_model(self) -> tuple[bytes | None, int]:
|
|
await self._cursor.execute(
|
|
"SELECT model, epoch FROM project WHERE project_id = %s", (self._project_id,)
|
|
)
|
|
row = await self._cursor.fetchone()
|
|
if row is None or row[0] is None:
|
|
model = None
|
|
epoch = 0
|
|
else:
|
|
model = bytes(row[0])
|
|
epoch = row[1]
|
|
return (model, epoch)
|
|
|
|
async def add_objects(
|
|
self,
|
|
dependencies: list[DependencyEntry],
|
|
custom_dependencies: list[CustomDependency],
|
|
objects: list[ObjectEntry],
|
|
):
|
|
if len(dependencies) > 0:
|
|
await self._cursor.executemany(
|
|
"INSERT INTO dependencies "
|
|
"(project_id, savepoint_id_start, savepoint_id_end, container_id, "
|
|
" configuration_hash, object_id, model_path)"
|
|
"VALUES (%s, %s, %s, %s, %s, %s, %s) "
|
|
"ON CONFLICT DO NOTHING",
|
|
(
|
|
(
|
|
self._project_id,
|
|
d.savepoint_id_start,
|
|
d.savepoint_id_end,
|
|
d.container_id,
|
|
d.configuration_hash,
|
|
d.object_id,
|
|
d.model_path,
|
|
)
|
|
for d in dependencies
|
|
),
|
|
)
|
|
|
|
if custom_dependencies:
|
|
await self._cursor.executemany(
|
|
"INSERT INTO custom_dependencies"
|
|
"(project_id, pipe_id, configuration_hash, argument_index, object_id, data) "
|
|
"VALUES (%s, %s, %s, %s, %s, %s)"
|
|
"ON CONFLICT (project_id, pipe_id, configuration_hash, argument_index, "
|
|
" object_id) DO UPDATE SET data = EXCLUDED.data",
|
|
(
|
|
(
|
|
self._project_id,
|
|
cd.pipe_id,
|
|
cd.configuration_hash,
|
|
cd.argument_index,
|
|
cd.object_id,
|
|
cd.data,
|
|
)
|
|
for cd in custom_dependencies
|
|
),
|
|
)
|
|
|
|
await self._cursor.executemany(
|
|
"INSERT INTO objects "
|
|
"(project_id, savepoint_id, container_id, configuration_hash, object_id, "
|
|
"object_id_string, content) VALUES (%s, %s, %s, %s, %s, %s, %s) "
|
|
"ON CONFLICT (project_id, savepoint_id, container_id, configuration_hash, "
|
|
" object_id) DO UPDATE SET content = EXCLUDED.content",
|
|
(
|
|
(
|
|
self._project_id,
|
|
o.savepoint_id,
|
|
o.container_id,
|
|
o.configuration_hash,
|
|
o.object_id,
|
|
o.object_id_string,
|
|
o.content,
|
|
)
|
|
for o in objects
|
|
),
|
|
)
|
|
|
|
await self._cursor.execute(
|
|
"UPDATE project SET last_object_save = NOW() WHERE project_id = %s",
|
|
(self._project_id,),
|
|
)
|
|
|
|
async def list_objects(self, savepoint_id: int, container_id: str) -> list[str]:
|
|
await self._cursor.execute(
|
|
"SELECT DISTINCT object_id_string FROM objects WHERE "
|
|
"project_id = %s AND savepoint_id = %s AND container_id = %s",
|
|
(self._project_id, savepoint_id, container_id),
|
|
)
|
|
return await _row_generator(self._cursor, lambda r: r[0])
|
|
|
|
async def get_object(
|
|
self, savepoint_id: int, container_id: str, object_id: str
|
|
) -> bytes | None:
|
|
await self._cursor.execute(
|
|
"SELECT content FROM objects WHERE "
|
|
"project_id = %s AND savepoint_id = %s AND container_id = %s AND object_id_string = %s",
|
|
(self._project_id, savepoint_id, container_id, object_id),
|
|
)
|
|
row = await self._cursor.fetchone()
|
|
return None if row is None else row[0]
|
|
|
|
async def invalidate_and_set_model(
|
|
self,
|
|
model_paths: list[str],
|
|
additional_objects: list[AdditionalObjectEntry],
|
|
model_bytes: bytes,
|
|
) -> ModelSetResult:
|
|
async with self._parent._upgrade_to_commiting_lock(self._lock_id):
|
|
invalidated_rows = await self._run_invalidation(model_paths, additional_objects)
|
|
|
|
await self._cursor.execute(
|
|
"UPDATE project SET model = %s, epoch = epoch + 1, last_model_save = NOW()"
|
|
" WHERE project_id = %s RETURNING epoch",
|
|
(model_bytes, self._project_id),
|
|
)
|
|
row = await self._cursor.fetchone()
|
|
assert row is not None
|
|
|
|
return ModelSetResult(new_epoch=row[0], invalidated=invalidated_rows)
|
|
|
|
async def put_file(self, hash_: str, content: bytes):
|
|
# Check if the file has already been uploaded, this saves us from
|
|
# shipping the (possibly big) binary over to the DB
|
|
await self._cursor.execute(
|
|
"SELECT 1 FROM file_storage WHERE project_id = %s and hash = %s",
|
|
(self._project_id, hash_),
|
|
)
|
|
if (await self._cursor.fetchone()) is not None:
|
|
return
|
|
|
|
await self._cursor.execute(
|
|
"INSERT INTO file_storage (project_id, hash, content) VALUES (%s, %s, %s)"
|
|
"ON CONFLICT (project_id, hash) DO NOTHING",
|
|
(self._project_id, hash_, content),
|
|
)
|
|
|
|
async def get_files(self, hashes: list[str]) -> dict[str, bytes]:
|
|
if len(hashes) == 0:
|
|
return {}
|
|
|
|
await self._cursor.execute(
|
|
"SELECT hash, content FROM file_storage WHERE project_id = %s AND hash = ANY(%s)",
|
|
(self._project_id, hashes),
|
|
)
|
|
result = {}
|
|
async for row in self._cursor:
|
|
result[row[0]] = bytes(row[1])
|
|
return result
|