Skip to content

Commit 15d395b

Browse files
committed
Store diffbase/progress in a shared sqlite database
Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
1 parent c627df6 commit 15d395b

7 files changed

Lines changed: 922 additions & 1 deletion

File tree

docs/configuration.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,25 @@ Usually, a mounted volume or a shared filesystem work fine,
490490
but exercise caution with locally running operators on developer machines
491491
with no access to the same directory/filesystem.
492492

493+
Storing progress in sqlite
494+
--------------------------
495+
496+
To store the state in a SQLite database:
497+
498+
.. code-block:: python
499+
500+
import kopf
501+
502+
@kopf.on.startup()
503+
def configure(settings: kopf.OperatorSettings, **_):
504+
settings.persistence.progress_storage = kopf.SQLiteProgressStorage(path='/var/kopf/state.db')
505+
506+
Each handler's progress record is stored as a separate row in a ``progress``
507+
table, keyed by the resource's namespace, name, uid, and handler id. The table
508+
is created automatically on first use. A small touch annotation is still written
509+
to the Kubernetes object to trigger watch events for delayed handler retries.
510+
Multiple storage types can share the same database file.
511+
493512
Storing progress in multiple places
494513
-----------------------------------
495514

@@ -656,6 +675,25 @@ Usually, a mounted volume or a shared filesystem work fine,
656675
but exercise caution with locally running operators on developer machines
657676
with no access to the same directory/filesystem.
658677

678+
Storing diff base in sqlite
679+
---------------------------
680+
681+
To store the last-handled configuration in a SQLite database:
682+
683+
684+
.. code-block:: python
685+
686+
import kopf
687+
688+
@kopf.on.startup()
689+
def configure(settings: kopf.OperatorSettings, **_):
690+
settings.persistence.diffbase_storage = kopf.SQLiteDiffBaseStorage(path='/var/kopf/state.db')
691+
692+
Each resource's body essence is stored as a single row in a ``diffbase`` table,
693+
keyed by the resource's namespace, name, and uid. The table is created
694+
automatically on first use. Multiple storage types can share the same
695+
database file.
696+
659697
Storing diff base in multiple places
660698
------------------------------------
661699

kopf/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
AnnotationsDiffBaseStorage,
2626
StatusDiffBaseStorage,
2727
FileDiffBaseStorage,
28+
SQLiteDiffBaseStorage,
2829
MultiDiffBaseStorage,
2930
)
3031
from kopf._cogs.configs.progress import (
@@ -33,6 +34,7 @@
3334
AnnotationsProgressStorage,
3435
StatusProgressStorage,
3536
FileProgressStorage,
37+
SQLiteProgressStorage,
3638
MultiProgressStorage,
3739
SmartProgressStorage,
3840
)
@@ -238,12 +240,14 @@
238240
'AnnotationsDiffBaseStorage',
239241
'StatusDiffBaseStorage',
240242
'FileDiffBaseStorage',
243+
'SQLiteDiffBaseStorage',
241244
'MultiDiffBaseStorage',
242245
'ProgressRecord',
243246
'ProgressStorage',
244247
'AnnotationsProgressStorage',
245248
'StatusProgressStorage',
246249
'FileProgressStorage',
250+
'SQLiteProgressStorage',
247251
'MultiProgressStorage',
248252
'SmartProgressStorage',
249253
'RawEventType',

kopf/_cogs/configs/conventions.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import uuid
3939
import warnings
4040
from collections.abc import Collection, Iterable, Iterator
41-
from typing import Any
41+
from typing import Any, Protocol, cast, runtime_checkable
4242

4343
from kopf._cogs.structs import bodies, patches
4444

@@ -347,3 +347,104 @@ def _temp_filename(self, path: pathlib.Path) -> Iterator[pathlib.Path]:
347347
raise
348348
else:
349349
temp.rename(path) # atomic overwrite
350+
351+
352+
# Sqlite3 is sometimes broken, so we import only on demand, so we cannot use it in annotations.
353+
# Add more methods as needed. Only actually used methods are listed here. There can be more.
354+
class sqlite3_Cursor(Protocol):
355+
def fetchone(self) -> list[Any]: ...
356+
357+
358+
@runtime_checkable
359+
class sqlite3_Connection(Protocol):
360+
def execute(self, sql: str, parameters: tuple[Any, ...] = ()) -> sqlite3_Cursor: ...
361+
def close(self) -> None: ...
362+
def __enter__(self) -> Any: ...
363+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: ...
364+
365+
366+
class SQLiteConvention:
367+
"""
368+
A mixin for SQLite-based storages with optimistic table creation.
369+
370+
Operations are tried first; if the table does not exist, it is created
371+
and the operation is retried. This avoids upfront schema management
372+
and lets both storage types share the same database file when pointed
373+
to the same path.
374+
"""
375+
376+
_create_sql: str # to be defined by subclasses
377+
378+
# If path is None, we do not own the connection, someone else does, we just use it.
379+
# If path is set, we own the connection, create and close it as needed.
380+
_path: pathlib.Path | None
381+
_conn: sqlite3_Connection | None
382+
383+
def __init__(
384+
self,
385+
path_or_conn: sqlite3_Connection | str | pathlib.Path,
386+
/,
387+
**kwargs: Any,
388+
) -> None:
389+
super().__init__(**kwargs)
390+
import sqlite3 # sometimes broken, so import only on demand
391+
if isinstance(path_or_conn, (sqlite3_Connection, sqlite3.Connection)):
392+
self._conn = path_or_conn
393+
self._path = None
394+
else:
395+
self._conn = None
396+
self._path = pathlib.Path(path_or_conn)
397+
398+
async def __aenter__(self) -> None:
399+
if self._path is None:
400+
return
401+
import sqlite3 # sometimes broken, so import only on demand
402+
self._path.parent.mkdir(parents=True, exist_ok=True)
403+
self._conn = cast(sqlite3_Connection, sqlite3.connect(str(self._path)))
404+
405+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
406+
if self._path is not None and self._conn is not None:
407+
self._conn.close()
408+
self._conn = None
409+
410+
@staticmethod
411+
def _extract_keys(body: bodies.Body) -> tuple[str, str, str]:
412+
# Primary keys cannot store nulls, so we use empty strings as markers.
413+
# K8s does not allow empty names/uids, so we are safe from collisions.
414+
namespace = body.get('metadata', {}).get('namespace', '')
415+
name = body.get('metadata', {}).get('name', '')
416+
uid = body.get('metadata', {}).get('uid', '')
417+
return namespace, name, uid
418+
419+
def _execute(
420+
self,
421+
sql: str,
422+
params: tuple[Any, ...] = (),
423+
) -> sqlite3_Cursor:
424+
"""Execute SQL, creating the table optimistically if absent."""
425+
import sqlite3 # sometimes broken, so import only on demand
426+
427+
assert self._conn is not None
428+
try:
429+
return self._conn.execute(sql, params)
430+
except sqlite3.OperationalError as e:
431+
if 'no such table' not in str(e):
432+
raise
433+
self._conn.execute(self._create_sql)
434+
return self._conn.execute(sql, params)
435+
436+
def _try_execute(
437+
self,
438+
sql: str,
439+
params: tuple[Any, ...] = (),
440+
) -> sqlite3_Cursor | None:
441+
"""Execute SQL, returning None if the table does not exist."""
442+
import sqlite3 # sometimes broken, so import only on demand
443+
444+
assert self._conn is not None
445+
try:
446+
return self._conn.execute(sql, params)
447+
except sqlite3.OperationalError as e:
448+
if 'no such table' not in str(e):
449+
raise
450+
return None

kopf/_cogs/configs/diffbase.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,91 @@ async def erase(self, *, body: bodies.Body) -> None:
329329
filepath.unlink(missing_ok=True)
330330

331331

332+
class SQLiteDiffBaseStorage(conventions.SQLiteConvention, DiffBaseStorage):
333+
"""
334+
Diff-base storage in a SQLite database file.
335+
336+
Each resource's body essence is stored as a single row, keyed by the
337+
resource's namespace, name, and uid. The essence is stored as a JSON
338+
string.
339+
340+
An example of the ``diffbase`` table contents:
341+
342+
.. code-block:: text
343+
344+
namespace | name | uid | essence
345+
----------+--------+-------+---------------------------------------
346+
default | my-app | uid1 | {"spec":{"replicas":3,"image":"..."}}
347+
348+
This storage does not write anything to the Kubernetes object itself.
349+
Both the file and SQLite diff-base storages can share the same database
350+
file when pointed to the same path.
351+
"""
352+
353+
# We do not plan any migrations yet. We pray that this simple schema is sufficient forever.
354+
_create_sql = (
355+
'CREATE TABLE IF NOT EXISTS diffbase ('
356+
'namespace TEXT NOT NULL, '
357+
'name TEXT NOT NULL, '
358+
'uid TEXT NOT NULL, '
359+
'essence TEXT NOT NULL, '
360+
'PRIMARY KEY (namespace, name, uid))'
361+
)
362+
363+
def __init__(
364+
self,
365+
path_or_conn: conventions.sqlite3_Connection | str | pathlib.Path,
366+
/,
367+
*,
368+
ignored_fields: Iterable[dicts.FieldSpec] | None = None,
369+
) -> None:
370+
super().__init__(path_or_conn, ignored_fields=ignored_fields)
371+
372+
async def fetch(
373+
self,
374+
*,
375+
body: bodies.Body,
376+
) -> bodies.BodyEssence | None:
377+
namespace, name, uid = self._extract_keys(body)
378+
assert self._conn is not None
379+
with self._conn:
380+
cursor = self._try_execute(
381+
'SELECT essence FROM diffbase'
382+
' WHERE namespace=? AND name=? AND uid=?',
383+
(namespace, name, uid))
384+
if cursor is None:
385+
return None
386+
row = cursor.fetchone()
387+
if row is None:
388+
return None
389+
return cast(bodies.BodyEssence, json.loads(row[0]))
390+
391+
async def store(
392+
self,
393+
*,
394+
body: bodies.Body,
395+
patch: patches.Patch,
396+
essence: bodies.BodyEssence,
397+
) -> None:
398+
namespace, name, uid = self._extract_keys(body)
399+
encoded = json.dumps(dict(essence), separators=(',', ':'))
400+
assert self._conn is not None
401+
with self._conn:
402+
self._execute(
403+
'INSERT OR REPLACE INTO diffbase'
404+
' (namespace, name, uid, essence) VALUES (?, ?, ?, ?)',
405+
(namespace, name, uid, encoded))
406+
407+
async def erase(self, *, body: bodies.Body) -> None:
408+
namespace, name, uid = self._extract_keys(body)
409+
assert self._conn is not None
410+
with self._conn:
411+
self._try_execute(
412+
'DELETE FROM diffbase'
413+
' WHERE namespace=? AND name=? AND uid=?',
414+
(namespace, name, uid))
415+
416+
332417
class MultiDiffBaseStorage(DiffBaseStorage):
333418

334419
def __init__(

0 commit comments

Comments
 (0)