flexlock 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flexlock/config.py ADDED
@@ -0,0 +1,121 @@
1
+ """Configuration and constants for FlexLock.
2
+
3
+ This module defines default values and environment variable overrides.
4
+ All environment variables are prefixed with FLEXLOCK_.
5
+ """
6
+
7
+ import os
8
+
9
+
10
+ def get_env_int(key: str, default: int) -> int:
11
+ """Get integer from environment variable."""
12
+ value = os.environ.get(key)
13
+ if value is None:
14
+ return default
15
+ try:
16
+ return int(value)
17
+ except ValueError:
18
+ return default
19
+
20
+
21
+ def get_env_float(key: str, default: float) -> float:
22
+ """Get float from environment variable."""
23
+ value = os.environ.get(key)
24
+ if value is None:
25
+ return default
26
+ try:
27
+ return float(value)
28
+ except ValueError:
29
+ return default
30
+
31
+
32
+ def get_env_bool(key: str, default: bool) -> bool:
33
+ """Get boolean from environment variable."""
34
+ value = os.environ.get(key, "").lower()
35
+ if value in ("1", "true", "yes", "on"):
36
+ return True
37
+ elif value in ("0", "false", "no", "off"):
38
+ return False
39
+ return default
40
+
41
+
42
+ # ==================== Parallel Execution ====================
43
+
44
+ # Poll interval for checking task status (seconds)
45
+ POLL_INTERVAL = get_env_int("FLEXLOCK_POLL_INTERVAL", 10)
46
+
47
+ # Log frequency for progress updates (seconds)
48
+ LOG_FREQUENCY = get_env_int("FLEXLOCK_LOG_FREQUENCY", 15)
49
+
50
+ # Default number of parallel jobs
51
+ DEFAULT_N_JOBS = get_env_int("FLEXLOCK_DEFAULT_N_JOBS", 1)
52
+
53
+ # Default timeout for waiting on HPC jobs (seconds, None = no timeout)
54
+ _timeout_str = os.environ.get("FLEXLOCK_DEFAULT_TIMEOUT")
55
+ DEFAULT_TIMEOUT = int(_timeout_str) if _timeout_str else 3600
56
+
57
+
58
+ # ==================== Smart Run / Caching ====================
59
+
60
+ # Whether to warn when smart_run=True but search_dirs=None
61
+ WARN_SMART_RUN_NO_SEARCH_DIRS = get_env_bool("FLEXLOCK_WARN_SMART_RUN", True)
62
+
63
+
64
+ # ==================== Timestamp Format ====================
65
+
66
+ # Standard timestamp format for directory names
67
+ # ISO 8601 compatible: YYYY-MM-DDTHH-MM-SS
68
+ TIMESTAMP_FORMAT = os.environ.get("FLEXLOCK_TIMESTAMP_FORMAT", "%Y-%m-%dT%H-%M-%S")
69
+
70
+ # Timestamp format for snapshots (ISO 8601 with microseconds)
71
+ SNAPSHOT_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
72
+
73
+
74
+ # ==================== Database ====================
75
+
76
+ # Database filename suffix
77
+ DB_FILENAME_SUFFIX = ".tasks.db"
78
+
79
+ # Attempts for DB write transactions when SQLite reports "database is locked"
80
+ # (jittered exponential backoff between attempts, on top of busy_timeout).
81
+ DB_RETRY_ATTEMPTS = get_env_int("FLEXLOCK_DB_RETRY_ATTEMPTS", 8)
82
+
83
+ # Cap on the backoff delay between retry attempts (seconds)
84
+ DB_RETRY_MAX_BACKOFF = get_env_float("FLEXLOCK_DB_RETRY_MAX_BACKOFF", 5.0)
85
+
86
+ # Number of tasks a worker claims per DB transaction. Raise this (e.g. 16-64)
87
+ # when running hundreds of workers against one DB on a shared filesystem:
88
+ # claim and finish write-lock traffic both drop by this factor. Trade-off: a
89
+ # worker that dies strands up to this many claimed tasks until the controller
90
+ # reconciles orphans / --reclaim resets them.
91
+ CLAIM_BATCH = get_env_int("FLEXLOCK_CLAIM_BATCH", 1)
92
+
93
+
94
+ # ==================== Pre-flight checks ====================
95
+
96
+ # When set, a worker verifies it can actually create a CUDA context before
97
+ # claiming any task. Catches unhealthy GPU nodes (Fabric Manager down,
98
+ # driver/kernel mismatch → cudaErrorSystemNotReady/802) up front with a clear
99
+ # log, instead of an opaque traceback deep inside the user's training.
100
+ PREFLIGHT_CUDA = get_env_bool("FLEXLOCK_PREFLIGHT_CUDA", False)
101
+
102
+ # Default database filename pattern
103
+ DB_FILENAME_PATTERN = "run.lock{suffix}"
104
+
105
+
106
+ # ==================== Logging ====================
107
+
108
+ # Whether to configure logging by default
109
+ CONFIGURE_LOGGING = get_env_bool("FLEXLOCK_CONFIGURE_LOGGING", True)
110
+
111
+
112
+ # ==================== Debug Mode ====================
113
+
114
+ # Debug mode enabled
115
+ DEBUG = get_env_bool("FLEXLOCK_DEBUG", False)
116
+
117
+ # Debug mode disabled (takes precedence)
118
+ NODEBUG = get_env_bool("FLEXLOCK_NODEBUG", False)
119
+
120
+ # Debug strategy: 'auto', 'pdb', or 'inject'
121
+ DEBUG_STRATEGY = os.environ.get("FLEXLOCK_DEBUG_STRATEGY", "auto")
flexlock/context.py ADDED
@@ -0,0 +1,7 @@
1
+ """Shared context for a FlexLock run."""
2
+
3
+ from contextvars import ContextVar
4
+
5
+ # This context will hold information gathered during a run, like the git commit hash.
6
+ # It is initialized with an empty dictionary.
7
+ run_context: ContextVar[dict] = ContextVar("run_context", default={})
flexlock/data_hash.py ADDED
@@ -0,0 +1,310 @@
1
+ """Data hashing utilities for FlexLock."""
2
+
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ from pathlib import Path
7
+ import xxhash
8
+ import hashlib
9
+ from joblib import Parallel, delayed
10
+ from contextlib import contextmanager
11
+
12
+ from . import config
13
+
14
+ # Hash-format version. Bump when the on-disk hash algorithm changes so stale
15
+ # rows in an old cache file are never returned as valid hits. v2: dirhash now
16
+ # folds each file's relative path into the digest (previously content-only).
17
+ HASH_VERSION = 2
18
+
19
+ # --- Cache Configuration ---
20
+ CACHE_DIR = (
21
+ Path(
22
+ os.environ.get(
23
+ "FLEXLOCK_CACHE", os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")
24
+ )
25
+ )
26
+ / "flexlock"
27
+ )
28
+ CACHE_DB = CACHE_DIR / f"hashes_v{HASH_VERSION}.db"
29
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
30
+ DEFAULT_DIR_FILE_LIMIT = os.environ.get("FLEXLOCK_DIR_FILE_LIMIT", 1000)
31
+
32
+ # Thread-local storage for database connections
33
+ _thread_local_conns = threading.local()
34
+
35
+
36
+ @contextmanager
37
+ def _get_db():
38
+ """
39
+ A thread-safe context manager for SQLite database connections.
40
+
41
+ This function maintains a cache of connections per thread. A new connection
42
+ is created for each unique database path and reused for subsequent calls
43
+ with the same path within that thread.
44
+ """
45
+ # Use the absolute path as a reliable key for the connections dictionary.
46
+ db_path_str = str(CACHE_DB.resolve())
47
+
48
+ # Initialize the connections dictionary for the current thread if it doesn't exist.
49
+ if not hasattr(_thread_local_conns, "conns"):
50
+ _thread_local_conns.conns = {}
51
+
52
+ # Check if a connection for this specific db_path already exists in the thread's cache.
53
+ if db_path_str not in _thread_local_conns.conns:
54
+ # If not, create a new connection and add it to the cache.
55
+ try:
56
+ c = sqlite3.connect(db_path_str, check_same_thread=False)
57
+ # Set PRAGMA for better performance and concurrency.
58
+ c.execute("PRAGMA journal_mode=WAL")
59
+ c.execute("PRAGMA busy_timeout=15000")
60
+ c.execute(
61
+ "PRAGMA foreign_keys=ON"
62
+ ) # Good practice to enforce foreign key constraints
63
+ c.execute(
64
+ """
65
+ CREATE TABLE IF NOT EXISTS cache (
66
+ path TEXT PRIMARY KEY,
67
+ mtime REAL,
68
+ file_count INTEGER,
69
+ latest_mtime REAL,
70
+ hash TEXT,
71
+ is_dir INTEGER
72
+ )
73
+ """
74
+ )
75
+ _thread_local_conns.conns[db_path_str] = c
76
+ except sqlite3.Error as e:
77
+ print(f"Error connecting to database {db_path_str}: {e}")
78
+ raise
79
+
80
+ # Yield the connection from the thread's cache.
81
+ try:
82
+ yield _thread_local_conns.conns[db_path_str]
83
+ finally:
84
+ # Don't close the connection since we're caching it for reuse
85
+ pass
86
+
87
+
88
+ def _hash_file_content(path):
89
+ """Hashes a single file using XXHash."""
90
+ hasher = xxhash.xxh64()
91
+ with open(path, "rb") as f:
92
+ while chunk := f.read(65536):
93
+ hasher.update(chunk)
94
+ return hasher.hexdigest()
95
+
96
+
97
+ def _get_dir_stats(path: Path, limit: int):
98
+ """
99
+ Walks a directory to get the file count and the latest modification time.
100
+
101
+ If the file count exceeds the limit, it returns (limit + 1, 0) to signal
102
+ that the directory is "large".
103
+ """
104
+ count = 0
105
+ latest_mtime = path.stat().st_mtime
106
+
107
+ for root, _, files in os.walk(path):
108
+ count += len(files)
109
+ if count > limit:
110
+ return count, 0 # Exceeded limit, fallback mode
111
+
112
+ for name in files:
113
+ filepath = Path(root) / name
114
+ try:
115
+ mtime = filepath.stat().st_mtime
116
+ if mtime > latest_mtime:
117
+ latest_mtime = mtime
118
+ except OSError:
119
+ # File might be a broken symlink, etc.
120
+ pass
121
+ return count, latest_mtime
122
+
123
+
124
+ def dirhash(
125
+ path, match=None, ignore=None, jobs=1, algorithm=hashlib.md5, chunk_size=65536
126
+ ):
127
+ """
128
+ Computes a hash of the directory content using pathlib.glob for filtering.
129
+ """
130
+ base_path = Path(path)
131
+ if not base_path.is_dir():
132
+ raise ValueError(f"'{path}' is not a valid directory.")
133
+
134
+ # Default match to all files recursively if not specified
135
+ match_pattern = match if match is not None else "**/*"
136
+ if isinstance(match_pattern, str):
137
+ match_pattern = [match_pattern]
138
+
139
+ ignore_patterns = ignore if ignore is not None else []
140
+ if isinstance(ignore_patterns, str):
141
+ ignore_patterns = [ignore_patterns]
142
+
143
+ import glob
144
+ from pathlib import Path as p_path
145
+
146
+ # Find all files matching patterns
147
+ files_to_hash = []
148
+ for pattern in match_pattern:
149
+ files_to_hash.extend(base_path.glob(pattern))
150
+
151
+ files_to_hash = [f for f in files_to_hash if f.is_file()]
152
+
153
+ # Apply ignore patterns
154
+ final_files = []
155
+ for f in files_to_hash:
156
+ should_ignore = False
157
+ for pattern in ignore_patterns:
158
+ if f.match(pattern):
159
+ should_ignore = True
160
+ break
161
+ if not should_ignore:
162
+ final_files.append(f)
163
+
164
+ if not final_files:
165
+ return algorithm().hexdigest()
166
+
167
+ def _hash_file(filepath, algorithm, chunk_size):
168
+ """Hashes a single file."""
169
+ hasher = algorithm()
170
+ with open(filepath, "rb") as f:
171
+ while True:
172
+ data = f.read(chunk_size)
173
+ if not data:
174
+ break
175
+ hasher.update(data)
176
+ return hasher.hexdigest()
177
+
178
+ content_hashes = Parallel(n_jobs=jobs)(
179
+ delayed(_hash_file)(str(f), algorithm, chunk_size) for f in final_files
180
+ )
181
+
182
+ # Fold the file's path into the digest so that two directories with
183
+ # identical file *contents* under different names hash differently, and so
184
+ # that swapping contents between two paths changes the hash.
185
+ entries = [
186
+ f"{f.relative_to(base_path).as_posix()}\0{h}"
187
+ for f, h in zip(final_files, content_hashes)
188
+ ]
189
+
190
+ final_hasher = algorithm()
191
+ for entry in sorted(entries):
192
+ final_hasher.update(entry.encode("utf-8"))
193
+
194
+ return final_hasher.hexdigest()
195
+
196
+
197
+ def hash_data(
198
+ path,
199
+ match=None,
200
+ ignore=None,
201
+ jobs=4,
202
+ algorithm=xxhash.xxh64,
203
+ chunk_size=2**18,
204
+ use_cache=True,
205
+ ):
206
+ """
207
+ Computes a hash for a file or a directory, using an SQLite cache to avoid re-computation.
208
+ """
209
+ path = Path(path).resolve()
210
+ # Honour the ``use_cache`` argument; the env var can only *force* caching
211
+ # off (it never re-enables a cache the caller explicitly disabled).
212
+ use_cache = use_cache and not config.get_env_bool("FLEXLOCK_NO_CACHE", False)
213
+ dir_file_limit = int(
214
+ os.environ.get("FLEXLOCK_CACHE_DIR_FILE_LIMIT", DEFAULT_DIR_FILE_LIMIT)
215
+ )
216
+
217
+ if use_cache:
218
+ with _get_db() as conn:
219
+ cursor = conn.cursor()
220
+
221
+ if path.is_file():
222
+ # Check cache for file
223
+ cursor.execute(
224
+ "SELECT hash, mtime FROM cache WHERE path=? AND is_dir=0",
225
+ (str(path),),
226
+ )
227
+ row = cursor.fetchone()
228
+
229
+ if row:
230
+ cached_hash, cached_mtime = row
231
+ current_mtime = path.stat().st_mtime
232
+ if cached_mtime == current_mtime:
233
+ return cached_hash
234
+ elif path.is_dir():
235
+ # Check cache for directory
236
+ cursor.execute(
237
+ "SELECT hash, mtime, file_count, latest_mtime FROM cache WHERE path=? AND is_dir=1",
238
+ (str(path),),
239
+ )
240
+ row = cursor.fetchone()
241
+
242
+ if row:
243
+ (
244
+ cached_hash,
245
+ cached_mtime,
246
+ cached_file_count,
247
+ cached_latest_mtime,
248
+ ) = row
249
+ file_count, latest_mtime = _get_dir_stats(path, dir_file_limit)
250
+
251
+ if file_count > dir_file_limit:
252
+ # Large directory fallback
253
+ current_mtime = path.stat().st_mtime
254
+ if cached_mtime == current_mtime:
255
+ return cached_hash
256
+ else:
257
+ if (
258
+ cached_file_count == file_count
259
+ and cached_latest_mtime == latest_mtime
260
+ ):
261
+ return cached_hash
262
+
263
+ # If not in cache or cache is invalid/disabled, compute the hash
264
+ if not path.exists():
265
+ raise FileNotFoundError(f"The specified path does not exist: {path}")
266
+
267
+ new_hash = None
268
+ if path.is_file():
269
+ new_hash = _hash_file_content(path)
270
+ elif path.is_dir():
271
+ new_hash = dirhash(
272
+ path,
273
+ match=match,
274
+ ignore=ignore,
275
+ jobs=jobs,
276
+ algorithm=algorithm,
277
+ chunk_size=chunk_size,
278
+ )
279
+
280
+ if new_hash is None:
281
+ raise ValueError(f"Could not compute hash for path: {path}")
282
+
283
+ # Update and save the cache if enabled
284
+ if use_cache:
285
+ with _get_db() as conn:
286
+ cursor = conn.cursor()
287
+ if path.is_file():
288
+ mtime = path.stat().st_mtime
289
+ cursor.execute(
290
+ "INSERT OR REPLACE INTO cache VALUES (?, ?, NULL, NULL, ?, 0)",
291
+ (str(path), mtime, new_hash),
292
+ )
293
+ elif path.is_dir():
294
+ file_count, latest_mtime = _get_dir_stats(path, dir_file_limit)
295
+ mtime = path.stat().st_mtime
296
+ if file_count > dir_file_limit:
297
+ # For large directories, use just the directory's mtime
298
+ cursor.execute(
299
+ "INSERT OR REPLACE INTO cache VALUES (?, ?, NULL, NULL, ?, 1)",
300
+ (str(path), mtime, new_hash),
301
+ )
302
+ else:
303
+ # For smaller directories, cache more detailed stats
304
+ cursor.execute(
305
+ "INSERT OR REPLACE INTO cache VALUES (?, ?, ?, ?, ?, 1)",
306
+ (str(path), mtime, file_count, latest_mtime, new_hash),
307
+ )
308
+ conn.commit()
309
+
310
+ return new_hash