CoolFace
Apppublic

umair894/quickstart-trackio

sourceHugging Faceupdated 11mo agoView on Hugging Face
0likes
sqlite_storage.py678 linesDownload Raw Back to root
1import os2import platform3import sqlite34import time5from datetime import datetime6from pathlib import Path7from threading import Lock8 9try:10    import fcntl11except ImportError:  # fcntl is not available on Windows12    fcntl = None13 14import huggingface_hub as hf15import orjson16import pandas as pd17 18try:  # absolute imports when installed from PyPI19    from trackio.commit_scheduler import CommitScheduler20    from trackio.dummy_commit_scheduler import DummyCommitScheduler21    from trackio.utils import (22        TRACKIO_DIR,23        deserialize_values,24        serialize_values,25    )26except ImportError:  # relative imports when installed from source on Spaces27    from commit_scheduler import CommitScheduler28    from dummy_commit_scheduler import DummyCommitScheduler29    from utils import TRACKIO_DIR, deserialize_values, serialize_values30 31DB_EXT = ".db"32 33 34class ProcessLock:35    """A file-based lock that works across processes. Is a no-op on Windows."""36 37    def __init__(self, lockfile_path: Path):38        self.lockfile_path = lockfile_path39        self.lockfile = None40        self.is_windows = platform.system() == "Windows"41 42    def __enter__(self):43        """Acquire the lock with retry logic."""44        if self.is_windows:45            return self46        self.lockfile_path.parent.mkdir(parents=True, exist_ok=True)47        self.lockfile = open(self.lockfile_path, "w")48 49        max_retries = 10050        for attempt in range(max_retries):51            try:52                fcntl.flock(self.lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)53                return self54            except IOError:55                if attempt < max_retries - 1:56                    time.sleep(0.1)57                else:58                    raise IOError("Could not acquire database lock after 10 seconds")59 60    def __exit__(self, exc_type, exc_val, exc_tb):61        """Release the lock."""62        if self.is_windows:63            return64 65        if self.lockfile:66            fcntl.flock(self.lockfile.fileno(), fcntl.LOCK_UN)67            self.lockfile.close()68 69 70class SQLiteStorage:71    _dataset_import_attempted = False72    _current_scheduler: CommitScheduler | DummyCommitScheduler | None = None73    _scheduler_lock = Lock()74 75    @staticmethod76    def _get_connection(db_path: Path) -> sqlite3.Connection:77        conn = sqlite3.connect(str(db_path), timeout=30.0)78        # Keep WAL for concurrency + performance on many small writes79        conn.execute("PRAGMA journal_mode = WAL")80        # ---- Minimal perf tweaks for many tiny transactions ----81        # NORMAL = fsync at critical points only (safer than OFF, much faster than FULL)82        conn.execute("PRAGMA synchronous = NORMAL")83        # Keep temp data in memory to avoid disk hits during small writes84        conn.execute("PRAGMA temp_store = MEMORY")85        # Give SQLite a bit more room for cache (negative = KB, engine-managed)86        conn.execute("PRAGMA cache_size = -20000")87        # --------------------------------------------------------88        conn.row_factory = sqlite3.Row89        return conn90 91    @staticmethod92    def _get_process_lock(project: str) -> ProcessLock:93        lockfile_path = TRACKIO_DIR / f"{project}.lock"94        return ProcessLock(lockfile_path)95 96    @staticmethod97    def get_project_db_filename(project: str) -> str:98        """Get the database filename for a specific project."""99        safe_project_name = "".join(100            c for c in project if c.isalnum() or c in ("-", "_")101        ).rstrip()102        if not safe_project_name:103            safe_project_name = "default"104        return f"{safe_project_name}{DB_EXT}"105 106    @staticmethod107    def get_project_db_path(project: str) -> Path:108        """Get the database path for a specific project."""109        filename = SQLiteStorage.get_project_db_filename(project)110        return TRACKIO_DIR / filename111 112    @staticmethod113    def init_db(project: str) -> Path:114        """115        Initialize the SQLite database with required tables.116        Returns the database path.117        """118        db_path = SQLiteStorage.get_project_db_path(project)119        db_path.parent.mkdir(parents=True, exist_ok=True)120        with SQLiteStorage._get_process_lock(project):121            with sqlite3.connect(str(db_path), timeout=30.0) as conn:122                conn.execute("PRAGMA journal_mode = WAL")123                conn.execute("PRAGMA synchronous = NORMAL")124                conn.execute("PRAGMA temp_store = MEMORY")125                conn.execute("PRAGMA cache_size = -20000")126                cursor = conn.cursor()127                cursor.execute(128                    """129                    CREATE TABLE IF NOT EXISTS metrics (130                        id INTEGER PRIMARY KEY AUTOINCREMENT,131                        timestamp TEXT NOT NULL,132                        run_name TEXT NOT NULL,133                        step INTEGER NOT NULL,134                        metrics TEXT NOT NULL135                    )136                    """137                )138                cursor.execute(139                    """140                    CREATE TABLE IF NOT EXISTS configs (141                        id INTEGER PRIMARY KEY AUTOINCREMENT,142                        run_name TEXT NOT NULL,143                        config TEXT NOT NULL,144                        created_at TEXT NOT NULL,145                        UNIQUE(run_name)146                    )147                    """148                )149                cursor.execute(150                    """151                    CREATE INDEX IF NOT EXISTS idx_metrics_run_step152                    ON metrics(run_name, step)153                    """154                )155                cursor.execute(156                    """157                    CREATE INDEX IF NOT EXISTS idx_configs_run_name158                    ON configs(run_name)159                    """160                )161                cursor.execute(162                    """163                    CREATE INDEX IF NOT EXISTS idx_metrics_run_timestamp164                    ON metrics(run_name, timestamp)165                    """166                )167                conn.commit()168        return db_path169 170    @staticmethod171    def export_to_parquet():172        """173        Exports all projects' DB files as Parquet under the same path but with extension ".parquet".174        """175        # don't attempt to export (potentially wrong/blank) data before importing for the first time176        if not SQLiteStorage._dataset_import_attempted:177            return178        if not TRACKIO_DIR.exists():179            return180 181        all_paths = os.listdir(TRACKIO_DIR)182        db_names = [f for f in all_paths if f.endswith(DB_EXT)]183        for db_name in db_names:184            db_path = TRACKIO_DIR / db_name185            parquet_path = db_path.with_suffix(".parquet")186            if (not parquet_path.exists()) or (187                db_path.stat().st_mtime > parquet_path.stat().st_mtime188            ):189                with sqlite3.connect(str(db_path)) as conn:190                    df = pd.read_sql("SELECT * FROM metrics", conn)191                # break out the single JSON metrics column into individual columns192                metrics = df["metrics"].copy()193                metrics = pd.DataFrame(194                    metrics.apply(195                        lambda x: deserialize_values(orjson.loads(x))196                    ).values.tolist(),197                    index=df.index,198                )199                del df["metrics"]200                for col in metrics.columns:201                    df[col] = metrics[col]202 203                df.to_parquet(parquet_path)204 205    @staticmethod206    def _cleanup_wal_sidecars(db_path: Path) -> None:207        """Remove leftover -wal/-shm files for a DB basename (prevents disk I/O errors)."""208        for suffix in ("-wal", "-shm"):209            sidecar = Path(str(db_path) + suffix)210            try:211                if sidecar.exists():212                    sidecar.unlink()213            except Exception:214                pass215 216    @staticmethod217    def import_from_parquet():218        """219        Imports to all DB files that have matching files under the same path but with extension ".parquet".220        """221        if not TRACKIO_DIR.exists():222            return223 224        all_paths = os.listdir(TRACKIO_DIR)225        parquet_names = [f for f in all_paths if f.endswith(".parquet")]226        for pq_name in parquet_names:227            parquet_path = TRACKIO_DIR / pq_name228            db_path = parquet_path.with_suffix(DB_EXT)229 230            SQLiteStorage._cleanup_wal_sidecars(db_path)231 232            df = pd.read_parquet(parquet_path)233            # fix up df to have a single JSON metrics column234            if "metrics" not in df.columns:235                # separate other columns from metrics236                metrics = df.copy()237                other_cols = ["id", "timestamp", "run_name", "step"]238                df = df[other_cols]239                for col in other_cols:240                    del metrics[col]241                # combine them all into a single metrics col242                metrics = orjson.loads(metrics.to_json(orient="records"))243                df["metrics"] = [orjson.dumps(serialize_values(row)) for row in metrics]244 245            with sqlite3.connect(str(db_path), timeout=30.0) as conn:246                df.to_sql("metrics", conn, if_exists="replace", index=False)247                conn.commit()248 249    @staticmethod250    def get_scheduler():251        """252        Get the scheduler for the database based on the environment variables.253        This applies to both local and Spaces.254        """255        with SQLiteStorage._scheduler_lock:256            if SQLiteStorage._current_scheduler is not None:257                return SQLiteStorage._current_scheduler258            hf_token = os.environ.get("HF_TOKEN")259            dataset_id = os.environ.get("TRACKIO_DATASET_ID")260            space_repo_name = os.environ.get("SPACE_REPO_NAME")261            if dataset_id is None or space_repo_name is None:262                scheduler = DummyCommitScheduler()263            else:264                scheduler = CommitScheduler(265                    repo_id=dataset_id,266                    repo_type="dataset",267                    folder_path=TRACKIO_DIR,268                    private=True,269                    allow_patterns=["*.parquet", "media/**/*"],270                    squash_history=True,271                    token=hf_token,272                    on_before_commit=SQLiteStorage.export_to_parquet,273                )274            SQLiteStorage._current_scheduler = scheduler275            return scheduler276 277    @staticmethod278    def log(project: str, run: str, metrics: dict, step: int | None = None):279        """280        Safely log metrics to the database. Before logging, this method will ensure the database exists281        and is set up with the correct tables. It also uses a cross-process lock to prevent282        database locking errors when multiple processes access the same database.283 284        This method is not used in the latest versions of Trackio (replaced by bulk_log) but285        is kept for backwards compatibility for users who are connecting to a newer version of286        a Trackio Spaces dashboard with an older version of Trackio installed locally.287        """288        db_path = SQLiteStorage.init_db(project)289        with SQLiteStorage._get_process_lock(project):290            with SQLiteStorage._get_connection(db_path) as conn:291                cursor = conn.cursor()292                cursor.execute(293                    """294                    SELECT MAX(step) 295                    FROM metrics 296                    WHERE run_name = ?297                    """,298                    (run,),299                )300                last_step = cursor.fetchone()[0]301                current_step = (302                    0303                    if step is None and last_step is None304                    else (step if step is not None else last_step + 1)305                )306                current_timestamp = datetime.now().isoformat()307                cursor.execute(308                    """309                    INSERT INTO metrics310                    (timestamp, run_name, step, metrics)311                    VALUES (?, ?, ?, ?)312                    """,313                    (314                        current_timestamp,315                        run,316                        current_step,317                        orjson.dumps(serialize_values(metrics)),318                    ),319                )320                conn.commit()321 322    @staticmethod323    def bulk_log(324        project: str,325        run: str,326        metrics_list: list[dict],327        steps: list[int] | None = None,328        timestamps: list[str] | None = None,329        config: dict | None = None,330    ):331        """332        Safely log bulk metrics to the database. Before logging, this method will ensure the database exists333        and is set up with the correct tables. It also uses a cross-process lock to prevent334        database locking errors when multiple processes access the same database.335        """336        if not metrics_list:337            return338 339        if timestamps is None:340            timestamps = [datetime.now().isoformat()] * len(metrics_list)341 342        db_path = SQLiteStorage.init_db(project)343        with SQLiteStorage._get_process_lock(project):344            with SQLiteStorage._get_connection(db_path) as conn:345                cursor = conn.cursor()346 347                if steps is None:348                    steps = list(range(len(metrics_list)))349                elif any(s is None for s in steps):350                    cursor.execute(351                        "SELECT MAX(step) FROM metrics WHERE run_name = ?", (run,)352                    )353                    last_step = cursor.fetchone()[0]354                    current_step = 0 if last_step is None else last_step + 1355                    processed_steps = []356                    for step in steps:357                        if step is None:358                            processed_steps.append(current_step)359                            current_step += 1360                        else:361                            processed_steps.append(step)362                    steps = processed_steps363 364                if len(metrics_list) != len(steps) or len(metrics_list) != len(365                    timestamps366                ):367                    raise ValueError(368                        "metrics_list, steps, and timestamps must have the same length"369                    )370 371                data = []372                for i, metrics in enumerate(metrics_list):373                    data.append(374                        (375                            timestamps[i],376                            run,377                            steps[i],378                            orjson.dumps(serialize_values(metrics)),379                        )380                    )381 382                cursor.executemany(383                    """384                    INSERT INTO metrics385                    (timestamp, run_name, step, metrics)386                    VALUES (?, ?, ?, ?)387                    """,388                    data,389                )390 391                if config:392                    current_timestamp = datetime.now().isoformat()393                    cursor.execute(394                        """395                        INSERT OR REPLACE INTO configs396                        (run_name, config, created_at)397                        VALUES (?, ?, ?)398                        """,399                        (400                            run,401                            orjson.dumps(serialize_values(config)),402                            current_timestamp,403                        ),404                    )405 406                conn.commit()407 408    @staticmethod409    def get_logs(project: str, run: str) -> list[dict]:410        """Retrieve logs for a specific run. Logs include the step count (int) and the timestamp (datetime object)."""411        db_path = SQLiteStorage.get_project_db_path(project)412        if not db_path.exists():413            return []414 415        with SQLiteStorage._get_connection(db_path) as conn:416            cursor = conn.cursor()417            cursor.execute(418                """419                SELECT timestamp, step, metrics420                FROM metrics421                WHERE run_name = ?422                ORDER BY timestamp423                """,424                (run,),425            )426 427            rows = cursor.fetchall()428            results = []429            for row in rows:430                metrics = orjson.loads(row["metrics"])431                metrics = deserialize_values(metrics)432                metrics["timestamp"] = row["timestamp"]433                metrics["step"] = row["step"]434                results.append(metrics)435            return results436 437    @staticmethod438    def load_from_dataset():439        dataset_id = os.environ.get("TRACKIO_DATASET_ID")440        space_repo_name = os.environ.get("SPACE_REPO_NAME")441        if dataset_id is not None and space_repo_name is not None:442            hfapi = hf.HfApi()443            updated = False444            if not TRACKIO_DIR.exists():445                TRACKIO_DIR.mkdir(parents=True, exist_ok=True)446            with SQLiteStorage.get_scheduler().lock:447                try:448                    files = hfapi.list_repo_files(dataset_id, repo_type="dataset")449                    for file in files:450                        # Download parquet and media assets451                        if not (file.endswith(".parquet") or file.startswith("media/")):452                            continue453                        if (TRACKIO_DIR / file).exists():454                            continue455                        hf.hf_hub_download(456                            dataset_id, file, repo_type="dataset", local_dir=TRACKIO_DIR457                        )458                        updated = True459                except hf.errors.EntryNotFoundError:460                    pass461                except hf.errors.RepositoryNotFoundError:462                    pass463                if updated:464                    SQLiteStorage.import_from_parquet()465        SQLiteStorage._dataset_import_attempted = True466 467    @staticmethod468    def get_projects() -> list[str]:469        """470        Get list of all projects by scanning the database files in the trackio directory.471        """472        if not SQLiteStorage._dataset_import_attempted:473            SQLiteStorage.load_from_dataset()474 475        projects: set[str] = set()476        if not TRACKIO_DIR.exists():477            return []478 479        for db_file in TRACKIO_DIR.glob(f"*{DB_EXT}"):480            project_name = db_file.stem481            projects.add(project_name)482        return sorted(projects)483 484    @staticmethod485    def get_runs(project: str) -> list[str]:486        """Get list of all runs for a project."""487        db_path = SQLiteStorage.get_project_db_path(project)488        if not db_path.exists():489            return []490 491        with SQLiteStorage._get_connection(db_path) as conn:492            cursor = conn.cursor()493            cursor.execute(494                "SELECT DISTINCT run_name FROM metrics",495            )496            return [row[0] for row in cursor.fetchall()]497 498    @staticmethod499    def get_max_steps_for_runs(project: str) -> dict[str, int]:500        """Get the maximum step for each run in a project."""501        db_path = SQLiteStorage.get_project_db_path(project)502        if not db_path.exists():503            return {}504 505        with SQLiteStorage._get_connection(db_path) as conn:506            cursor = conn.cursor()507            cursor.execute(508                """509                SELECT run_name, MAX(step) as max_step510                FROM metrics511                GROUP BY run_name512                """513            )514 515            results = {}516            for row in cursor.fetchall():517                results[row["run_name"]] = row["max_step"]518 519            return results520 521    @staticmethod522    def store_config(project: str, run: str, config: dict) -> None:523        """Store configuration for a run."""524        db_path = SQLiteStorage.init_db(project)525 526        with SQLiteStorage._get_process_lock(project):527            with SQLiteStorage._get_connection(db_path) as conn:528                cursor = conn.cursor()529                current_timestamp = datetime.now().isoformat()530 531                cursor.execute(532                    """533                    INSERT OR REPLACE INTO configs534                    (run_name, config, created_at)535                    VALUES (?, ?, ?)536                    """,537                    (run, orjson.dumps(serialize_values(config)), current_timestamp),538                )539                conn.commit()540 541    @staticmethod542    def get_run_config(project: str, run: str) -> dict | None:543        """Get configuration for a specific run."""544        db_path = SQLiteStorage.get_project_db_path(project)545        if not db_path.exists():546            return None547 548        with SQLiteStorage._get_connection(db_path) as conn:549            cursor = conn.cursor()550            try:551                cursor.execute(552                    """553                    SELECT config FROM configs WHERE run_name = ?554                    """,555                    (run,),556                )557 558                row = cursor.fetchone()559                if row:560                    config = orjson.loads(row["config"])561                    return deserialize_values(config)562                return None563            except sqlite3.OperationalError as e:564                if "no such table: configs" in str(e):565                    return None566                raise567 568    @staticmethod569    def delete_run(project: str, run: str) -> bool:570        """Delete a run from the database (both metrics and config)."""571        db_path = SQLiteStorage.get_project_db_path(project)572        if not db_path.exists():573            return False574 575        with SQLiteStorage._get_process_lock(project):576            with SQLiteStorage._get_connection(db_path) as conn:577                cursor = conn.cursor()578                try:579                    cursor.execute("DELETE FROM metrics WHERE run_name = ?", (run,))580                    cursor.execute("DELETE FROM configs WHERE run_name = ?", (run,))581                    conn.commit()582                    return True583                except sqlite3.Error:584                    return False585 586    @staticmethod587    def get_all_run_configs(project: str) -> dict[str, dict]:588        """Get configurations for all runs in a project."""589        db_path = SQLiteStorage.get_project_db_path(project)590        if not db_path.exists():591            return {}592 593        with SQLiteStorage._get_connection(db_path) as conn:594            cursor = conn.cursor()595            try:596                cursor.execute(597                    """598                    SELECT run_name, config FROM configs599                    """600                )601 602                results = {}603                for row in cursor.fetchall():604                    config = orjson.loads(row["config"])605                    results[row["run_name"]] = deserialize_values(config)606                return results607            except sqlite3.OperationalError as e:608                if "no such table: configs" in str(e):609                    return {}610                raise611 612    @staticmethod613    def get_metric_values(project: str, run: str, metric_name: str) -> list[dict]:614        """Get all values for a specific metric in a project/run."""615        db_path = SQLiteStorage.get_project_db_path(project)616        if not db_path.exists():617            return []618 619        with SQLiteStorage._get_connection(db_path) as conn:620            cursor = conn.cursor()621            cursor.execute(622                """623                SELECT timestamp, step, metrics624                FROM metrics625                WHERE run_name = ?626                ORDER BY timestamp627                """,628                (run,),629            )630 631            rows = cursor.fetchall()632            results = []633            for row in rows:634                metrics = orjson.loads(row["metrics"])635                metrics = deserialize_values(metrics)636                if metric_name in metrics:637                    results.append(638                        {639                            "timestamp": row["timestamp"],640                            "step": row["step"],641                            "value": metrics[metric_name],642                        }643                    )644            return results645 646    @staticmethod647    def get_all_metrics_for_run(project: str, run: str) -> list[str]:648        """Get all metric names for a specific project/run."""649        db_path = SQLiteStorage.get_project_db_path(project)650        if not db_path.exists():651            return []652 653        with SQLiteStorage._get_connection(db_path) as conn:654            cursor = conn.cursor()655            cursor.execute(656                """657                SELECT metrics658                FROM metrics659                WHERE run_name = ?660                ORDER BY timestamp661                """,662                (run,),663            )664 665            rows = cursor.fetchall()666            all_metrics = set()667            for row in rows:668                metrics = orjson.loads(row["metrics"])669                metrics = deserialize_values(metrics)670                for key in metrics.keys():671                    if key not in ["timestamp", "step"]:672                        all_metrics.add(key)673            return sorted(list(all_metrics))674 675    def finish(self):676        """Cleanup when run is finished."""677        pass678