Skip to content
Closed
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
188 commits
Select commit Hold shift + click to select a range
7f876e8
using session instead of catalog in udfstep
ilongin Oct 20, 2025
a5c4572
refactoring job creation in datachain
ilongin Oct 20, 2025
70a44a6
implementing first phase of UDF checkpoints
ilongin Oct 22, 2025
f4c848b
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Oct 22, 2025
d8a337a
refactoring
ilongin Oct 22, 2025
d7b5ed9
changing udf table names
ilongin Oct 23, 2025
8752a9a
adding checkpoint tests and fixing cleaning udf tables in test
ilongin Oct 23, 2025
862fe28
added udf checkpoint continue from partial results
ilongin Oct 26, 2025
b599429
added udf generator logic and tests
ilongin Oct 27, 2025
3804b0c
merging with main
ilongin Oct 27, 2025
7c05e0d
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Oct 27, 2025
20346e7
fixing logic
ilongin Oct 27, 2025
e0242c9
merging with main
ilongin Oct 28, 2025
8fd41af
fixing issues and tests
ilongin Oct 28, 2025
630e37b
refactoring tests
ilongin Oct 28, 2025
b31d44a
refactoring
ilongin Oct 28, 2025
b5bb8cd
refactoring
ilongin Oct 29, 2025
a5f0fcd
refactoring
ilongin Oct 29, 2025
92590f7
refactoring udf table ownership logic
ilongin Oct 30, 2025
3c2211d
refactoring
ilongin Oct 30, 2025
181ea2e
refactoring tests
ilongin Oct 30, 2025
88c2648
fixing cast of recursive sql
ilongin Oct 30, 2025
c0f46cb
using has_table instead checking metadata
ilongin Oct 31, 2025
14e473b
fixing tests
ilongin Nov 4, 2025
d68d746
fixing cleaning table and partition by
ilongin Nov 6, 2025
8e0339f
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 6, 2025
08c9ec4
fixing test
ilongin Nov 6, 2025
bb50da7
fixing aggregator
ilongin Nov 6, 2025
bd7d978
fixing hash collision
ilongin Nov 7, 2025
4ddee8f
merging with main
ilongin Nov 7, 2025
5b80a87
refactoring and removing processed table
ilongin Nov 7, 2025
9a6c71f
fixing tests
ilongin Nov 7, 2025
a2d6b34
fixing tests
ilongin Nov 7, 2025
3621cde
returning
ilongin Nov 7, 2025
437b63c
updated coverage
ilongin Nov 7, 2025
76125d0
removed coverate sysmon
ilongin Nov 7, 2025
c644aa1
refactoring checkpoint cleaning
ilongin Nov 7, 2025
5f6f183
Remove cleanup_checkpoints functionality for separate PR
ilongin Nov 9, 2025
91f7da5
Add cleanup_checkpoints functionality
ilongin Nov 9, 2025
709873c
fixing tests
ilongin Nov 9, 2025
e180338
fixing tests
ilongin Nov 9, 2025
aaf43f9
added udf checkpoint docs
ilongin Nov 10, 2025
1488fab
refactoring
ilongin Nov 10, 2025
d7f3a50
fixing tests
ilongin Nov 10, 2025
8a2ec11
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 10, 2025
4379d11
fix creating processed table even in reset mode
ilongin Nov 10, 2025
27033e5
added tests
ilongin Nov 11, 2025
d73d55d
refactoring processed tracking for generators
ilongin Nov 12, 2025
b15f1c9
refactoring tests
ilongin Nov 12, 2025
fd5019e
refactoring create_table method
ilongin Nov 12, 2025
e4e6de9
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 13, 2025
1e7f941
fix re-run when UDF output changes
ilongin Nov 13, 2025
6a5c140
Update src/datachain/cli/commands/misc.py
ilongin Nov 13, 2025
f914b7f
fixing docs and some code parts
ilongin Nov 13, 2025
91aa89c
refactoring
ilongin Nov 13, 2025
452ae72
returning sysmon
ilongin Nov 14, 2025
2375237
renaming create_checkpoint method
ilongin Nov 14, 2025
55b3846
simplified logic
ilongin Nov 14, 2025
87a51f3
removing batch_callback
ilongin Nov 16, 2025
43835f5
merging with main
ilongin Nov 17, 2025
911a3dc
refactoring
ilongin Nov 19, 2025
ad2907d
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 19, 2025
0b3e092
removing tracking_fiedl
ilongin Nov 19, 2025
e909118
fixing tests
ilongin Nov 19, 2025
7bbe619
fixing ancestor job id find
ilongin Nov 20, 2025
fa5053b
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 20, 2025
140e56b
refactor remove_checkpoint to accept only id
ilongin Nov 21, 2025
54c4493
removed comment
ilongin Nov 21, 2025
e9f48f5
refactoring creating table
ilongin Nov 21, 2025
38fa81d
refactoring
ilongin Nov 21, 2025
8ab52ae
updated docs by removing parent verb
ilongin Nov 21, 2025
ced14b4
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Nov 23, 2025
0f88905
adding staging sufix for table atomicity when doing copy
ilongin Nov 23, 2025
6f7e06f
break parent connection when reset flag is present
ilongin Nov 24, 2025
af784f2
fixing docs
ilongin Nov 24, 2025
7703eb5
fixing docs and other small fixes
ilongin Nov 24, 2025
4a7b4ca
fixing docs and other small fixes
ilongin Nov 24, 2025
a95a764
fixing comments
ilongin Nov 24, 2025
8876a3b
discarding changes with garabage collecting method of cli
ilongin Nov 24, 2025
13f3552
moving list_tables function to tests util
ilongin Nov 24, 2025
157a437
unifying prepare_row functions
ilongin Nov 24, 2025
c9d3bb0
adding hash_input and hash_output as default args in apply method of …
ilongin Nov 24, 2025
2227fe1
renaming sys_id to sys__processed_id
ilongin Nov 24, 2025
f459d60
removed not needed quote_schema from sqlite in removing tables for test
ilongin Nov 24, 2025
f93b49d
fixing issue with incomplete inputs in generator
ilongin Dec 10, 2025
a2a9a98
merging with main
ilongin Dec 10, 2025
631cdb3
merging with main
ilongin Dec 10, 2025
49e7641
added docs
ilongin Dec 10, 2025
f50058f
reorganizing tests
ilongin Dec 10, 2025
d11fb5d
var renaming
ilongin Dec 10, 2025
96f9de9
added regression test for subtract
ilongin Dec 10, 2025
ec98372
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Dec 13, 2025
c77858a
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Dec 17, 2025
9a51f9c
make hash_callable not fail if unexpected callalbe is input
ilongin Dec 17, 2025
298bcf3
disable checkpoints in threading / multiprocess
Dec 17, 2025
aa11f80
added custom migration function for checkpoints
ilongin Dec 18, 2025
ee464fb
Merge branch 'ilongin/1392-udf-checkpoints' of github.com:datachain-a…
ilongin Dec 18, 2025
e2ab50b
renaming checkpointstable and removing not needed migration function
Dec 19, 2025
9cb16c7
Merge branch 'main' into ilongin/1392-udf-checkpoints
Dec 19, 2025
3685dca
fixing non determinisitc tests for CH
ilongin Dec 19, 2025
eba46b5
fixing bug with continuing udf processing
ilongin Dec 20, 2025
e58d742
fixing test
ilongin Dec 20, 2025
1feac6d
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Dec 21, 2025
13c6aa0
fixing docs
ilongin Dec 21, 2025
a878df6
removed not needde comments
ilongin Dec 22, 2025
c61a13b
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Dec 29, 2025
d88d68a
removed not needed flag
ilongin Dec 29, 2025
abccfbc
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Dec 31, 2025
809e9a3
removed not needed env var
ilongin Dec 31, 2025
ab6799f
renamed env var
ilongin Dec 31, 2025
fa00047
reduced number of parallel
ilongin Dec 31, 2025
da8fd5b
added envs to env docs
ilongin Dec 31, 2025
115ea69
moved function to check concurrency for checkpoints from session to u…
ilongin Dec 31, 2025
334f5fb
removed comment
ilongin Dec 31, 2025
cb18ee4
fixing correct parent id
ilongin Jan 1, 2026
d1f83f1
moving check if checkpoint is enabled because of concurency from meta…
ilongin Jan 2, 2026
79655e7
removed partial constraint
ilongin Jan 2, 2026
b93f328
removing test
ilongin Jan 2, 2026
4352423
refactoring test
ilongin Jan 2, 2026
2504498
refactoring and merging with main
ilongin Jan 6, 2026
97fe6ae
fixing comment
ilongin Jan 6, 2026
26f1eef
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 7, 2026
5049e96
Merge branch 'ilongin/1392-udf-checkpoints' into ilongin/1453-checkpo…
ilongin Jan 7, 2026
985415d
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 8, 2026
31fe6dd
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 8, 2026
f117751
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 12, 2026
15751ba
returning old checkpoints table name
ilongin Jan 12, 2026
85305b0
refactoring input table name hash
ilongin Jan 13, 2026
cafeadf
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 14, 2026
a022bc1
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 15, 2026
27781df
using group id for input table name in udf
ilongin Jan 15, 2026
05cf600
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 19, 2026
af0dc7f
using pid and thread ownership to determine if checkpoints are enable…
ilongin Jan 19, 2026
b108dc8
fixing test
ilongin Jan 19, 2026
24c3894
refactoring tests
ilongin Jan 19, 2026
eb0e03a
refactoring tests
ilongin Jan 19, 2026
9d93aad
removing not needed conditions
ilongin Jan 21, 2026
e8ec502
refactoring
ilongin Jan 21, 2026
3bcfa18
fixing comment
ilongin Jan 21, 2026
4dd9cd4
refactoring
ilongin Jan 21, 2026
8fdfea2
merging with main
ilongin Jan 22, 2026
34402f4
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 22, 2026
8ea8b12
fixing race condition
ilongin Jan 22, 2026
acf79c8
adde safe_copy_table
ilongin Jan 22, 2026
585d685
refactoring copy_table methods
ilongin Jan 23, 2026
adb828e
continuing UDF if parent partial table is not found
ilongin Jan 23, 2026
797b6cd
added try/catch of missing table
ilongin Jan 23, 2026
505f304
refactor transaction context usage
ilongin Jan 23, 2026
d702b21
optimized query
ilongin Jan 23, 2026
8df4905
added thread lock
ilongin Jan 23, 2026
659cc1c
updated docs with hashing limitations
ilongin Jan 23, 2026
b90a9d6
renaming function
ilongin Jan 23, 2026
3431c10
removed unrelated lint exception
ilongin Jan 26, 2026
19093a3
refactoring checkpoint tests
ilongin Jan 26, 2026
6803345
fixing env vars and verbose comments
ilongin Jan 26, 2026
8c57340
ading runtime error
ilongin Jan 26, 2026
d25b5af
refactoring
ilongin Jan 26, 2026
7a44193
removing name and job_aware to hash method of DataChain
ilongin Jan 26, 2026
e267deb
refactoring
ilongin Jan 26, 2026
bee6e0f
merging with main
ilongin Jan 26, 2026
0144c1a
refactoring
ilongin Jan 27, 2026
c457bf9
refactoring
ilongin Jan 27, 2026
15650eb
added logs
ilongin Jan 27, 2026
495f189
fixing env vars
ilongin Jan 27, 2026
8e6b2e5
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Jan 28, 2026
56c6b78
refactoring tests
ilongin Jan 28, 2026
8b16be4
removing not neededd monkeypatch
ilongin Jan 28, 2026
88048de
merging with main
ilongin Jan 30, 2026
e903715
added more tests
ilongin Jan 30, 2026
79c94f2
closing sqlite connections in test
ilongin Jan 30, 2026
a83fafe
moving get_table to db specific implementation
ilongin Jan 30, 2026
1d77ac2
return get_table to db_engine
ilongin Jan 30, 2026
0de0d3f
added job_id to hash
ilongin Feb 2, 2026
8b8a8d3
improved logging
ilongin Feb 3, 2026
0f61ea7
Added `CheckpointEvent` model to track checkpoint events (#1575)
ilongin Feb 4, 2026
25def9c
added prints
ilongin Feb 6, 2026
5a70e41
added print only when it is second job
ilongin Feb 9, 2026
12a771c
removed not used var
ilongin Feb 9, 2026
41847c9
removed print
ilongin Feb 9, 2026
6279013
merging with base branch
ilongin Feb 9, 2026
85e0c42
Merge branch 'main' into ilongin/1392-udf-checkpoints
ilongin Feb 10, 2026
3e6601c
fixing reading files on udf continue
ilongin Feb 10, 2026
a9358d1
UDF checkpoint visibility (#1576)
ilongin Feb 12, 2026
22ebd7d
merged with main
ilongin Feb 12, 2026
1a04a7c
refactoring checkpoint events
ilongin Feb 12, 2026
5998b8e
fixing lint
ilongin Feb 12, 2026
e83884b
adding missing tests and fixing issues
ilongin Feb 14, 2026
6e18191
Merge branch 'ilongin/1392-udf-checkpoints' into ilongin/1453-checkpo…
ilongin Feb 16, 2026
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
8 changes: 3 additions & 5 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,9 @@ def bench(session: nox.Session) -> None:
def tests(session: nox.Session) -> None:
session.install(".[tests]")
env = {"COVERAGE_FILE": f".coverage.{session.python}"}
if session.python in ("3.12", "3.13"):
# improve performance of tests in Python>=3.12 when used with coverage
# https://github.com/nedbat/coveragepy/issues/1665
# https://github.com/python/cpython/issues/107674
env["COVERAGE_CORE"] = "sysmon"
# Note: Previously used COVERAGE_CORE=sysmon for Python 3.12/3.13 performance,
# but sysmon doesn't support branch coverage in those versions.
# Removed to avoid: "Can't use core=sysmon: sys.monitoring can't measure branches"
session.run(
"pytest",
"--cov",
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ tests = [
"pytest-asyncio",
"pytest-sugar>=0.9.6",
"pytest-cov>=4.1.0",
"coverage>=7.11.1",
"pytest-mock>=3.12.0",
"pytest-servers[all]>=0.5.9",
"pytest-benchmark[histogram]",
Expand Down
65 changes: 65 additions & 0 deletions src/datachain/catalog/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from copy import copy
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from functools import cached_property, reduce
from threading import Thread
from typing import IO, TYPE_CHECKING, Any, NoReturn
Expand All @@ -22,6 +23,7 @@
from tqdm.auto import tqdm

from datachain.cache import Cache
from datachain.checkpoint import Checkpoint
from datachain.client import Client
from datachain.dataset import (
DATASET_PREFIX,
Expand Down Expand Up @@ -2039,3 +2041,66 @@ def index(
client_config=client_config or self.client_config,
only_index=True,
)

def _remove_checkpoint(self, checkpoint: Checkpoint) -> None:
"""
Remove a checkpoint and its associated job-specific UDF tables.

Since tables are now job-scoped, this removes only the tables
belonging to this specific checkpoint's job.

Args:
checkpoint: The checkpoint object to remove.
"""
# Remove the checkpoint from metastore first
self.metastore.remove_checkpoint(checkpoint)

# Remove job-specific tables for this checkpoint
# Table patterns: udf_{job_id}_{hash}_{suffix}
# where suffix can be: input, output, output_partial, processed
job_id_sanitized = checkpoint.job_id.replace("-", "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Sanitizing job_id by removing hyphens may not be sufficient for table name safety.

Consider validating job_id to exclude all invalid characters and reserved words to ensure table names remain safe and compliant.

Suggested implementation:

        # Remove job-specific tables for this checkpoint
        # Table patterns: udf_{job_id}_{hash}_{suffix}
        # where suffix can be: input, output, output_partial, processed
        job_id_sanitized = self._sanitize_job_id(checkpoint.job_id)
        table_prefix = f"udf_{job_id_sanitized}_{checkpoint.hash}_"
        matching_tables = self.warehouse.db.list_tables(prefix=table_prefix)
    def _sanitize_job_id(self, job_id: str) -> str:
        """
        Sanitize job_id for safe table naming:
        - Remove invalid characters (only allow alphanumeric and underscores)
        - Ensure it does not match reserved words (simple check)
        """
        import re

        # Only allow alphanumeric and underscores
        sanitized = re.sub(r'[^a-zA-Z0-9_]', '', job_id)

        # List of reserved words (example, expand as needed)
        reserved_words = {"select", "table", "from", "where", "insert", "update", "delete"}
        if sanitized.lower() in reserved_words:
            sanitized = f"job_{sanitized}"

        return sanitized

    def cleanup_checkpoints(self, ttl_seconds: int | None = None) -> None:
        """
        Clean up outdated checkpoints and their associated UDF tables.

table_prefix = f"udf_{job_id_sanitized}_{checkpoint.hash}_"
matching_tables = self.warehouse.db.list_tables(prefix=table_prefix)

if matching_tables:
Comment on lines +2058 to +2060

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)

Suggested change
matching_tables = self.warehouse.db.list_tables(prefix=table_prefix)
if matching_tables:
if matching_tables := self.warehouse.db.list_tables(prefix=table_prefix):

self.warehouse.cleanup_tables(matching_tables)

def cleanup_checkpoints(self, ttl_seconds: int | None = None) -> None:
"""
Clean up outdated checkpoints and their associated UDF tables.

Uses optimized branch pruning: removes outdated checkpoints if no
descendants have active (non-outdated) checkpoints that depend on them.

This prevents accumulation of checkpoints while ensuring that ancestor
tables are preserved when descendants still need them.

Args:
ttl_seconds: Time-to-live in seconds. Checkpoints older than this
are considered outdated. If None, uses CHECKPOINT_TTL
environment variable or default.
"""
if ttl_seconds is None:
ttl_seconds = int(os.environ.get("CHECKPOINT_TTL", str(TTL_INT)))

ttl_threshold = datetime.now(timezone.utc) - timedelta(seconds=ttl_seconds)

# Cache descendant check results per job_id to avoid redundant checks
has_active_descendants_cache: dict[str, bool] = {}

# For each outdated checkpoint, check if it's safe to remove
for ch in self.metastore.list_checkpoints(created_before=ttl_threshold):
# Check once per job_id if descendants have active checkpoints (cached)
if ch.job_id not in has_active_descendants_cache:
has_active_descendants_cache[ch.job_id] = any(
list(
self.metastore.list_checkpoints(
desc_id, created_after=ttl_threshold
)
)
for desc_id in self.metastore.get_descendant_job_ids(ch.job_id)
)

# If no active descendants, remove the checkpoint
if not has_active_descendants_cache[ch.job_id]:
self._remove_checkpoint(ch)
14 changes: 10 additions & 4 deletions src/datachain/cli/commands/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ def clear_cache(catalog: "Catalog"):

def garbage_collect(catalog: "Catalog"):
temp_tables = catalog.get_temp_table_names()
if not temp_tables:
print("Nothing to clean up.")
else:
print(f"Garbage collecting {len(temp_tables)} tables.")
has_tables = bool(temp_tables)

if has_tables:
print(f"Garbage collecting {len(temp_tables)} temporary tables.")
catalog.cleanup_tables(temp_tables)

print("Cleaning up outdated checkpoints.")
catalog.cleanup_checkpoints()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Consider handling and reporting errors from cleanup_checkpoints.

Wrap catalog.cleanup_checkpoints() in a try/except block to prevent abrupt termination and provide user-friendly error reporting.

Suggested change
print("Cleaning up outdated checkpoints.")
catalog.cleanup_checkpoints()
print("Cleaning up outdated checkpoints.")
try:
catalog.cleanup_checkpoints()
except Exception as e:
print(f"Error cleaning up checkpoints: {e}")


if not has_tables:
print("No temporary tables to clean up.")


def completion(shell: str) -> str:
from datachain.cli import get_parser
Expand Down
25 changes: 21 additions & 4 deletions src/datachain/data_storage/db_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,19 @@ def execute(
) -> Iterator[tuple[Any, ...]]: ...

def get_table(self, name: str) -> "Table":
from datachain.error import TableMissingError

table = self.metadata.tables.get(name)
if table is None:
sa.Table(name, self.metadata, autoload_with=self.engine)
# ^^^ This table may not be correctly initialised on some dialects
# Grab it from metadata instead.
table = self.metadata.tables[name]
try:
sa.Table(name, self.metadata, autoload_with=self.engine)
# ^^^ This table may not be correctly initialised on some dialects
# Grab it from metadata instead.
table = self.metadata.tables.get(name)
if table is None:
raise TableMissingError(f"Table '{name}' not found")
except (KeyError, sa.exc.NoSuchTableError) as e:
raise TableMissingError(f"Table '{name}' not found") from e
return table

@abstractmethod
Expand All @@ -111,6 +118,16 @@ def has_table(self, name: str) -> bool:
"""
return sa.inspect(self.engine).has_table(name)

def list_tables(self, prefix: str = "") -> list[str]:
"""
Return a list of table names that start with the given prefix.
If no prefix is provided, returns all table names.
"""
all_tables = sa.inspect(self.engine).get_table_names()
if not prefix:
return all_tables
return [table for table in all_tables if table.startswith(prefix)]

@abstractmethod
def create_table(
self,
Expand Down
172 changes: 155 additions & 17 deletions src/datachain/data_storage/metastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Table,
Text,
UniqueConstraint,
cast,
desc,
literal,
select,
Expand Down Expand Up @@ -422,6 +423,20 @@ def create_job(
def get_job(self, job_id: str) -> Job | None:
"""Returns the job with the given ID."""

@abstractmethod
def get_ancestor_job_ids(self, job_id: str, conn=None) -> list[str]:
"""
Returns list of ancestor job IDs in order from parent to root.
Uses recursive CTE to get all ancestors in a single query.
"""

@abstractmethod
def get_descendant_job_ids(self, job_id: str, conn=None) -> list[str]:
"""
Returns list of descendant job IDs (children, grandchildren, etc.).
Uses recursive CTE to get all descendants in a single query.
"""

@abstractmethod
def update_job(
self,
Expand Down Expand Up @@ -457,8 +472,24 @@ def get_last_job_by_name(self, name: str, conn=None) -> "Job | None":
#

@abstractmethod
def list_checkpoints(self, job_id: str, conn=None) -> Iterator[Checkpoint]:
"""Returns all checkpoints related to some job"""
def list_checkpoints(
self,
job_id: str | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
conn=None,
) -> Iterator[Checkpoint]:
"""
List checkpoints by job id, or all checkpoints if job_id is None.

Args:
job_id: Filter by job ID. If None, lists all checkpoints.
created_after: Filter by creation date. If provided, only returns
checkpoints created after this timestamp.
created_before: Filter by creation date. If provided, only returns
checkpoints created before this timestamp.
conn: Database connection to use.
"""

@abstractmethod
def get_last_checkpoint(self, job_id: str, conn=None) -> Checkpoint | None:
Expand All @@ -485,6 +516,12 @@ def create_checkpoint(
) -> Checkpoint:
"""Creates new checkpoint"""

@abstractmethod
def remove_checkpoint(
self, checkpoint: Checkpoint, conn: Any | None = None
) -> None:
"""Removes a checkpoint by checkpoint object"""


class AbstractDBMetastore(AbstractMetastore):
"""
Expand Down Expand Up @@ -1746,6 +1783,73 @@ def get_job(self, job_id: str, conn=None) -> Job | None:
return None
return self._parse_job(results[0])

def get_ancestor_job_ids(self, job_id: str, conn=None) -> list[str]:
# Use recursive CTE to walk up the parent chain
# Format: WITH RECURSIVE ancestors(id, parent_job_id) AS (...)
ancestors_cte = (
select(
self._jobs.c.id.label("id"),
self._jobs.c.parent_job_id.label("parent_job_id"),
)
.where(self._jobs.c.id == job_id)
.cte(name="ancestors", recursive=True)
)

# Recursive part: join with parent jobs
ancestors_recursive = ancestors_cte.union_all(
select(
self._jobs.c.id.label("id"),
self._jobs.c.parent_job_id.label("parent_job_id"),
).select_from(
self._jobs.join(
ancestors_cte,
self._jobs.c.id
== cast(ancestors_cte.c.parent_job_id, self._jobs.c.id.type),
)
)
)

# Select all ancestor IDs except the starting job itself
query = select(ancestors_recursive.c.id).where(
ancestors_recursive.c.id != job_id
)

results = list(self.db.execute(query, conn=conn))
return [str(row[0]) for row in results]

def get_descendant_job_ids(self, job_id: str, conn=None) -> list[str]:
# Use recursive CTE to walk down the child chain
descendants_cte = (
select(
self._jobs.c.id.label("id"),
self._jobs.c.parent_job_id.label("parent_job_id"),
)
.where(self._jobs.c.id == job_id)
.cte(name="descendants", recursive=True)
)

# Recursive part: join with child jobs
descendants_recursive = descendants_cte.union_all(
select(
self._jobs.c.id.label("id"),
self._jobs.c.parent_job_id.label("parent_job_id"),
).select_from(
self._jobs.join(
descendants_cte,
cast(self._jobs.c.parent_job_id, self._jobs.c.id.type)
== descendants_cte.c.id,
)
)
)

# Select all descendant IDs except the starting job itself
query = select(descendants_recursive.c.id).where(
descendants_recursive.c.id != job_id
)

results = list(self.db.execute(query, conn=conn))
return [str(row[0]) for row in results]

def update_job(
self,
job_id: str,
Expand Down Expand Up @@ -1833,7 +1937,7 @@ def _checkpoints_columns() -> "list[SchemaItem]":
Column("hash", Text, nullable=False),
Column("partial", Boolean, default=False),
Column("created_at", DateTime(timezone=True), nullable=False),
UniqueConstraint("job_id", "hash"),
UniqueConstraint("job_id", "hash", "partial"),
]

@cached_property
Expand Down Expand Up @@ -1872,24 +1976,48 @@ def create_checkpoint(
conn: Any | None = None,
) -> Checkpoint:
"""
Creates a new job query step.
Creates a new checkpoint or returns existing one if already exists.
This is idempotent - calling it multiple times with the same job_id and hash
will not create duplicates.
"""
# First check if checkpoint already exists
existing = self.find_checkpoint(job_id, _hash, partial=partial, conn=conn)
if existing:
return existing

checkpoint_id = str(uuid4())
self.db.execute(
self._checkpoints_insert().values(
id=checkpoint_id,
job_id=job_id,
hash=_hash,
partial=partial,
created_at=datetime.now(timezone.utc),
),
conn=conn,
query = self._checkpoints_insert().values(
id=checkpoint_id,
job_id=job_id,
hash=_hash,
partial=partial,
created_at=datetime.now(timezone.utc),
)
return self.get_checkpoint_by_id(checkpoint_id)

def list_checkpoints(self, job_id: str, conn=None) -> Iterator[Checkpoint]:
"""List checkpoints by job id."""
query = self._checkpoints_query().where(self._checkpoints.c.job_id == job_id)
# Use on_conflict_do_nothing to handle race conditions
if hasattr(query, "on_conflict_do_nothing"):
query = query.on_conflict_do_nothing(
index_elements=["job_id", "hash", "partial"]
)

self.db.execute(query, conn=conn)

return self.find_checkpoint(job_id, _hash, partial=partial, conn=conn) # type: ignore[return-value]

def list_checkpoints(
self,
job_id: str | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
conn=None,
) -> Iterator[Checkpoint]:
query = self._checkpoints_query()
if job_id is not None:
query = query.where(self._checkpoints.c.job_id == job_id)
if created_after is not None:
query = query.where(self._checkpoints.c.created_at >= created_after)
if created_before is not None:
query = query.where(self._checkpoints.c.created_at < created_before)
rows = list(self.db.execute(query, conn=conn))

yield from [self.checkpoint_class.parse(*r) for r in rows]
Expand Down Expand Up @@ -1929,3 +2057,13 @@ def get_last_checkpoint(self, job_id: str, conn=None) -> Checkpoint | None:
if not rows:
return None
return self.checkpoint_class.parse(*rows[0])

def remove_checkpoint(
self, checkpoint: Checkpoint, conn: Any | None = None
) -> None:
"""Removes a checkpoint by checkpoint object"""
ch = self._checkpoints
self.db.execute(
self._checkpoints_delete().where(ch.c.id == checkpoint.id),
conn=conn,
)
Loading
Loading