experimaestro 2.0.0b8__py3-none-any.whl → 2.0.0b17__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.
Potentially problematic release.
This version of experimaestro might be problematic. Click here for more details.
- experimaestro/__init__.py +12 -5
- experimaestro/cli/__init__.py +239 -126
- experimaestro/cli/filter.py +48 -23
- experimaestro/cli/jobs.py +253 -71
- experimaestro/cli/refactor.py +1 -2
- experimaestro/commandline.py +7 -4
- experimaestro/connectors/__init__.py +9 -1
- experimaestro/connectors/local.py +43 -3
- experimaestro/core/arguments.py +18 -18
- experimaestro/core/identifier.py +11 -11
- experimaestro/core/objects/config.py +96 -39
- experimaestro/core/objects/config_walk.py +3 -3
- experimaestro/core/{subparameters.py → partial.py} +16 -16
- experimaestro/core/partial_lock.py +394 -0
- experimaestro/core/types.py +12 -15
- experimaestro/dynamic.py +290 -0
- experimaestro/experiments/__init__.py +6 -2
- experimaestro/experiments/cli.py +217 -50
- experimaestro/experiments/configuration.py +24 -0
- experimaestro/generators.py +5 -5
- experimaestro/ipc.py +118 -1
- experimaestro/launcherfinder/__init__.py +2 -2
- experimaestro/launcherfinder/registry.py +6 -7
- experimaestro/launcherfinder/specs.py +2 -9
- experimaestro/launchers/slurm/__init__.py +2 -2
- experimaestro/launchers/slurm/base.py +62 -0
- experimaestro/locking.py +957 -1
- experimaestro/notifications.py +89 -201
- experimaestro/progress.py +63 -366
- experimaestro/rpyc.py +0 -2
- experimaestro/run.py +29 -2
- experimaestro/scheduler/__init__.py +8 -1
- experimaestro/scheduler/base.py +629 -53
- experimaestro/scheduler/dependencies.py +20 -16
- experimaestro/scheduler/experiment.py +732 -167
- experimaestro/scheduler/interfaces.py +316 -101
- experimaestro/scheduler/jobs.py +58 -20
- experimaestro/scheduler/remote/adaptive_sync.py +265 -0
- experimaestro/scheduler/remote/client.py +171 -117
- experimaestro/scheduler/remote/protocol.py +8 -193
- experimaestro/scheduler/remote/server.py +95 -71
- experimaestro/scheduler/services.py +53 -28
- experimaestro/scheduler/state_provider.py +663 -2430
- experimaestro/scheduler/state_status.py +1247 -0
- experimaestro/scheduler/transient.py +31 -0
- experimaestro/scheduler/workspace.py +1 -1
- experimaestro/scheduler/workspace_state_provider.py +1273 -0
- experimaestro/scriptbuilder.py +4 -4
- experimaestro/settings.py +36 -0
- experimaestro/tests/conftest.py +33 -5
- experimaestro/tests/connectors/bin/executable.py +1 -1
- experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
- experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
- experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
- experimaestro/tests/launchers/bin/test.py +1 -0
- experimaestro/tests/launchers/test_slurm.py +9 -9
- experimaestro/tests/partial_reschedule.py +46 -0
- experimaestro/tests/restart.py +3 -3
- experimaestro/tests/restart_main.py +1 -0
- experimaestro/tests/scripts/notifyandwait.py +1 -0
- experimaestro/tests/task_partial.py +38 -0
- experimaestro/tests/task_tokens.py +2 -2
- experimaestro/tests/tasks/test_dynamic.py +6 -6
- experimaestro/tests/test_dependencies.py +3 -3
- experimaestro/tests/test_deprecated.py +15 -15
- experimaestro/tests/test_dynamic_locking.py +317 -0
- experimaestro/tests/test_environment.py +24 -14
- experimaestro/tests/test_experiment.py +171 -36
- experimaestro/tests/test_identifier.py +25 -25
- experimaestro/tests/test_identifier_stability.py +3 -5
- experimaestro/tests/test_multitoken.py +2 -4
- experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
- experimaestro/tests/test_partial_paths.py +81 -138
- experimaestro/tests/test_pre_experiment.py +219 -0
- experimaestro/tests/test_progress.py +2 -8
- experimaestro/tests/test_remote_state.py +560 -99
- experimaestro/tests/test_stray_jobs.py +261 -0
- experimaestro/tests/test_tasks.py +1 -2
- experimaestro/tests/test_token_locking.py +52 -67
- experimaestro/tests/test_tokens.py +5 -6
- experimaestro/tests/test_transient.py +225 -0
- experimaestro/tests/test_workspace_state_provider.py +768 -0
- experimaestro/tests/token_reschedule.py +1 -3
- experimaestro/tests/utils.py +2 -7
- experimaestro/tokens.py +227 -372
- experimaestro/tools/diff.py +1 -0
- experimaestro/tools/documentation.py +4 -5
- experimaestro/tools/jobs.py +1 -2
- experimaestro/tui/app.py +438 -1966
- experimaestro/tui/app.tcss +162 -0
- experimaestro/tui/dialogs.py +172 -0
- experimaestro/tui/log_viewer.py +253 -3
- experimaestro/tui/messages.py +137 -0
- experimaestro/tui/utils.py +54 -0
- experimaestro/tui/widgets/__init__.py +23 -0
- experimaestro/tui/widgets/experiments.py +468 -0
- experimaestro/tui/widgets/global_services.py +238 -0
- experimaestro/tui/widgets/jobs.py +972 -0
- experimaestro/tui/widgets/log.py +156 -0
- experimaestro/tui/widgets/orphans.py +363 -0
- experimaestro/tui/widgets/runs.py +185 -0
- experimaestro/tui/widgets/services.py +314 -0
- experimaestro/tui/widgets/stray_jobs.py +528 -0
- experimaestro/utils/__init__.py +1 -1
- experimaestro/utils/environment.py +105 -22
- experimaestro/utils/fswatcher.py +124 -0
- experimaestro/utils/jobs.py +1 -2
- experimaestro/utils/jupyter.py +1 -2
- experimaestro/utils/logging.py +72 -0
- experimaestro/version.py +2 -2
- experimaestro/webui/__init__.py +9 -0
- experimaestro/webui/app.py +117 -0
- experimaestro/{server → webui}/data/index.css +66 -11
- experimaestro/webui/data/index.css.map +1 -0
- experimaestro/{server → webui}/data/index.js +82763 -87217
- experimaestro/webui/data/index.js.map +1 -0
- experimaestro/webui/routes/__init__.py +5 -0
- experimaestro/webui/routes/auth.py +53 -0
- experimaestro/webui/routes/proxy.py +117 -0
- experimaestro/webui/server.py +200 -0
- experimaestro/webui/state_bridge.py +152 -0
- experimaestro/webui/websocket.py +413 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
- experimaestro-2.0.0b17.dist-info/RECORD +219 -0
- experimaestro/cli/progress.py +0 -269
- experimaestro/scheduler/state.py +0 -75
- experimaestro/scheduler/state_db.py +0 -437
- experimaestro/scheduler/state_sync.py +0 -891
- experimaestro/server/__init__.py +0 -467
- experimaestro/server/data/index.css.map +0 -1
- experimaestro/server/data/index.js.map +0 -1
- experimaestro/tests/test_cli_jobs.py +0 -615
- experimaestro/tests/test_file_progress.py +0 -425
- experimaestro/tests/test_file_progress_integration.py +0 -477
- experimaestro/tests/test_state_db.py +0 -434
- experimaestro-2.0.0b8.dist-info/RECORD +0 -187
- /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
- /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
- /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
- /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
- /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
- /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
- /experimaestro/{server → webui}/data/favicon.ico +0 -0
- /experimaestro/{server → webui}/data/index.html +0 -0
- /experimaestro/{server → webui}/data/login.html +0 -0
- /experimaestro/{server → webui}/data/manifest.json +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
- {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
experimaestro/scheduler/jobs.py
CHANGED
|
@@ -9,11 +9,12 @@ from typing import TYPE_CHECKING, Iterator, List, Optional, Set
|
|
|
9
9
|
import concurrent
|
|
10
10
|
|
|
11
11
|
from experimaestro.core.objects import Config, ConfigWalkContext, WatchedOutput
|
|
12
|
-
from experimaestro.notifications import LevelInformation
|
|
12
|
+
from experimaestro.notifications import LevelInformation
|
|
13
13
|
|
|
14
14
|
# from experimaestro.scheduler.base import Scheduler
|
|
15
15
|
from experimaestro.scheduler.dependencies import Dependency, Resource
|
|
16
16
|
from experimaestro.scheduler.workspace import RunMode, Workspace
|
|
17
|
+
from experimaestro.scheduler.transient import TransientMode
|
|
17
18
|
from experimaestro.scheduler.interfaces import (
|
|
18
19
|
BaseJob,
|
|
19
20
|
JobState,
|
|
@@ -47,6 +48,7 @@ __all__ = [
|
|
|
47
48
|
"JobStateError",
|
|
48
49
|
"JobFailureStatus",
|
|
49
50
|
"Job",
|
|
51
|
+
"TransientMode",
|
|
50
52
|
]
|
|
51
53
|
|
|
52
54
|
|
|
@@ -63,9 +65,40 @@ class JobLock(Lock):
|
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
class JobDependency(Dependency):
|
|
68
|
+
origin: "Job"
|
|
69
|
+
|
|
66
70
|
def __init__(self, job):
|
|
67
71
|
super().__init__(job)
|
|
68
72
|
|
|
73
|
+
async def ensure_started(self):
|
|
74
|
+
"""Ensure the dependency job is started.
|
|
75
|
+
|
|
76
|
+
If the dependency is a transient job that was skipped (state is UNSCHEDULED),
|
|
77
|
+
this will start it so it actually runs.
|
|
78
|
+
"""
|
|
79
|
+
origin_job = self.origin
|
|
80
|
+
if (
|
|
81
|
+
origin_job.transient.is_transient
|
|
82
|
+
and origin_job.state == JobState.UNSCHEDULED
|
|
83
|
+
):
|
|
84
|
+
# Transient job was skipped but now is needed - start it
|
|
85
|
+
from experimaestro.utils import logger
|
|
86
|
+
|
|
87
|
+
logger.info(
|
|
88
|
+
"Starting transient job %s (needed by dependent job)",
|
|
89
|
+
origin_job.identifier,
|
|
90
|
+
)
|
|
91
|
+
# Mark as needed so aio_submit won't skip it again
|
|
92
|
+
origin_job._needed_transient = True
|
|
93
|
+
# Mark as WAITING and start the job via aio_submit
|
|
94
|
+
# Use aio_submit (not aio_start) to properly handle all job lifecycle
|
|
95
|
+
origin_job.set_state(JobState.WAITING)
|
|
96
|
+
if origin_job.scheduler is not None:
|
|
97
|
+
# Create a new future for the job so aio_lock can wait on it
|
|
98
|
+
origin_job._future = asyncio.ensure_future(
|
|
99
|
+
origin_job.scheduler.aio_submit(origin_job)
|
|
100
|
+
)
|
|
101
|
+
|
|
69
102
|
async def aio_lock(self, timeout: float = 0):
|
|
70
103
|
"""Acquire lock on job dependency by waiting for job to complete
|
|
71
104
|
|
|
@@ -81,6 +114,9 @@ class JobDependency(Dependency):
|
|
|
81
114
|
"Job dependencies only support timeout=0 (wait indefinitely)"
|
|
82
115
|
)
|
|
83
116
|
|
|
117
|
+
# Ensure the dependency job is started (handles transient jobs)
|
|
118
|
+
await self.ensure_started()
|
|
119
|
+
|
|
84
120
|
# Wait for the job to finish
|
|
85
121
|
if self.origin._future is None:
|
|
86
122
|
raise RuntimeError(f"Job {self.origin} has no future - not submitted")
|
|
@@ -89,8 +125,7 @@ class JobDependency(Dependency):
|
|
|
89
125
|
# Check if the job succeeded
|
|
90
126
|
if self.origin.state != JobState.DONE:
|
|
91
127
|
raise RuntimeError(
|
|
92
|
-
f"Dependency job {self.origin.identifier} failed with state "
|
|
93
|
-
f"{self.origin.state} for {self.target.identifier}"
|
|
128
|
+
f"Dependency job {self.origin.identifier} failed with state {self.origin.state} for {self.target.identifier}"
|
|
94
129
|
)
|
|
95
130
|
|
|
96
131
|
# Job succeeded, acquire and return the lock
|
|
@@ -113,6 +148,7 @@ class Job(BaseJob, Resource):
|
|
|
113
148
|
launcher: "Launcher" = None,
|
|
114
149
|
run_mode: RunMode = RunMode.NORMAL,
|
|
115
150
|
max_retries: Optional[int] = None,
|
|
151
|
+
transient: TransientMode = TransientMode.NONE,
|
|
116
152
|
):
|
|
117
153
|
from experimaestro.scheduler.base import Scheduler
|
|
118
154
|
|
|
@@ -150,6 +186,12 @@ class Job(BaseJob, Resource):
|
|
|
150
186
|
self.max_retries = max_retries if max_retries is not None else 3
|
|
151
187
|
self.retry_count = 0
|
|
152
188
|
|
|
189
|
+
# Transient mode for intermediary tasks
|
|
190
|
+
self.transient = transient if transient is not None else TransientMode.NONE
|
|
191
|
+
# Flag set when a transient job's mode is merged to non-transient,
|
|
192
|
+
# indicating the job should run even though it was originally transient
|
|
193
|
+
self._needed_transient = False
|
|
194
|
+
|
|
153
195
|
# Watched outputs (stored for deferred registration with scheduler)
|
|
154
196
|
self.watched_outputs: List["WatchedOutput"] = list(
|
|
155
197
|
config.__xpm__.watched_outputs
|
|
@@ -233,7 +275,7 @@ class Job(BaseJob, Resource):
|
|
|
233
275
|
logger.debug(
|
|
234
276
|
"Job %s submitted, unfinished jobs for %s: %d",
|
|
235
277
|
self.identifier[:8],
|
|
236
|
-
xp.
|
|
278
|
+
xp.name,
|
|
237
279
|
xp.unfinishedJobs,
|
|
238
280
|
)
|
|
239
281
|
elif not is_counted(new_state) and is_counted(old_state):
|
|
@@ -242,7 +284,7 @@ class Job(BaseJob, Resource):
|
|
|
242
284
|
logger.debug(
|
|
243
285
|
"Job %s finished, unfinished jobs for %s: %d",
|
|
244
286
|
self.identifier[:8],
|
|
245
|
-
xp.
|
|
287
|
+
xp.name,
|
|
246
288
|
xp.unfinishedJobs,
|
|
247
289
|
)
|
|
248
290
|
|
|
@@ -301,16 +343,6 @@ class Job(BaseJob, Resource):
|
|
|
301
343
|
self._progress[-1].desc = desc
|
|
302
344
|
self._progress[-1].progress = value
|
|
303
345
|
|
|
304
|
-
# Notify listeners via scheduler's thread-safe mechanism
|
|
305
|
-
self.scheduler.notify_job_state(self)
|
|
306
|
-
|
|
307
|
-
def add_notification_server(self, server):
|
|
308
|
-
"""Adds a notification server"""
|
|
309
|
-
key, baseurl = server.getNotificationSpec()
|
|
310
|
-
dirpath = self.path / Reporter.NOTIFICATION_FOLDER
|
|
311
|
-
dirpath.mkdir(exist_ok=True)
|
|
312
|
-
(dirpath / key).write_text(f"{baseurl}/{self.identifier}")
|
|
313
|
-
|
|
314
346
|
@property
|
|
315
347
|
def ready(self):
|
|
316
348
|
return self.state == JobState.READY
|
|
@@ -428,6 +460,12 @@ class Job(BaseJob, Resource):
|
|
|
428
460
|
logger.info("Rotating log file %s -> %s", log_path.name, new_name)
|
|
429
461
|
log_path.rename(new_path)
|
|
430
462
|
|
|
463
|
+
def process_state_dict(self) -> dict | None:
|
|
464
|
+
"""Get process state as dictionary."""
|
|
465
|
+
if self._process is not None:
|
|
466
|
+
return self._process.tospec()
|
|
467
|
+
return None
|
|
468
|
+
|
|
431
469
|
@property
|
|
432
470
|
def basepath(self) -> Path:
|
|
433
471
|
return self.jobpath / self.name
|
|
@@ -454,28 +492,28 @@ class JobContext(ConfigWalkContext):
|
|
|
454
492
|
def task(self):
|
|
455
493
|
return self.job.config
|
|
456
494
|
|
|
457
|
-
def partial_path(self,
|
|
458
|
-
"""Returns the partial directory path for a given
|
|
495
|
+
def partial_path(self, partial, config) -> Path:
|
|
496
|
+
"""Returns the partial directory path for a given partial instance.
|
|
459
497
|
|
|
460
498
|
The partial path structure is:
|
|
461
499
|
WORKSPACE/partials/TASK_ID/SUBPARAM_NAME/PARTIAL_ID/
|
|
462
500
|
|
|
463
501
|
Args:
|
|
464
|
-
|
|
502
|
+
partial: The Partial instance defining which groups to exclude
|
|
465
503
|
config: The configuration to compute the partial identifier for
|
|
466
504
|
|
|
467
505
|
Returns:
|
|
468
506
|
The partial directory path.
|
|
469
507
|
"""
|
|
470
508
|
# Compute partial identifier
|
|
471
|
-
partial_id = config.__xpm__.get_partial_identifier(
|
|
509
|
+
partial_id = config.__xpm__.get_partial_identifier(partial)
|
|
472
510
|
|
|
473
511
|
# Build partial directory path
|
|
474
512
|
task_id = str(config.__xpmtype__.identifier)
|
|
475
513
|
return (
|
|
476
514
|
self.job.workspace.partialspath
|
|
477
515
|
/ task_id
|
|
478
|
-
/
|
|
516
|
+
/ partial.name
|
|
479
517
|
/ partial_id.all.hex()
|
|
480
518
|
)
|
|
481
519
|
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Adaptive file synchronization for remote monitoring
|
|
2
|
+
|
|
3
|
+
Provides background rsync with adaptive intervals based on change frequency.
|
|
4
|
+
- Minimum interval: 10 seconds (to avoid overloading)
|
|
5
|
+
- Maximum interval: 5 minutes (to ensure eventual updates)
|
|
6
|
+
- Adapts based on whether changes are detected
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("xpm.remote.sync")
|
|
16
|
+
|
|
17
|
+
# Sync interval limits (in seconds)
|
|
18
|
+
MIN_SYNC_INTERVAL = 10.0
|
|
19
|
+
MAX_SYNC_INTERVAL = 300.0 # 5 minutes
|
|
20
|
+
INITIAL_SYNC_INTERVAL = 15.0
|
|
21
|
+
|
|
22
|
+
# Interval adjustment factors
|
|
23
|
+
SPEEDUP_FACTOR = 0.7 # When changes detected, reduce interval
|
|
24
|
+
SLOWDOWN_FACTOR = 1.5 # When no changes, increase interval
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdaptiveSynchronizer:
|
|
28
|
+
"""Background synchronizer with adaptive intervals
|
|
29
|
+
|
|
30
|
+
Syncs a remote path periodically, adjusting the interval based on
|
|
31
|
+
whether changes are detected.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
sync_func: Callable[[str], Optional[Path]],
|
|
37
|
+
remote_path: str,
|
|
38
|
+
name: str = "",
|
|
39
|
+
on_sync_start: Optional[Callable[[], None]] = None,
|
|
40
|
+
on_sync_complete: Optional[Callable[[Path], None]] = None,
|
|
41
|
+
on_sync_error: Optional[Callable[[str], None]] = None,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize the synchronizer
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
sync_func: Function to call for syncing (e.g., state_provider.sync_path)
|
|
47
|
+
remote_path: Remote path to sync
|
|
48
|
+
name: Human-readable name for logging (e.g., "job:task_id" or "service:tensorboard")
|
|
49
|
+
on_sync_start: Callback when sync starts
|
|
50
|
+
on_sync_complete: Callback when sync completes with local path
|
|
51
|
+
on_sync_error: Callback when sync fails with error message
|
|
52
|
+
"""
|
|
53
|
+
self.sync_func = sync_func
|
|
54
|
+
self.remote_path = remote_path
|
|
55
|
+
self.name = name or remote_path
|
|
56
|
+
self.on_sync_start = on_sync_start
|
|
57
|
+
self.on_sync_complete = on_sync_complete
|
|
58
|
+
self.on_sync_error = on_sync_error
|
|
59
|
+
self._syncing = False
|
|
60
|
+
|
|
61
|
+
self._interval = INITIAL_SYNC_INTERVAL
|
|
62
|
+
self._running = False
|
|
63
|
+
self._thread: Optional[threading.Thread] = None
|
|
64
|
+
self._stop_event = threading.Event()
|
|
65
|
+
|
|
66
|
+
# Track file modification times to detect changes
|
|
67
|
+
self._last_mtimes: dict[str, float] = {}
|
|
68
|
+
self._local_path: Optional[Path] = None
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def interval(self) -> float:
|
|
72
|
+
"""Current sync interval"""
|
|
73
|
+
return self._interval
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def local_path(self) -> Optional[Path]:
|
|
77
|
+
"""Local path after sync (None if not synced yet)"""
|
|
78
|
+
return self._local_path
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def syncing(self) -> bool:
|
|
82
|
+
"""Whether a sync is currently in progress"""
|
|
83
|
+
return self._syncing
|
|
84
|
+
|
|
85
|
+
def start(self) -> None:
|
|
86
|
+
"""Start background syncing"""
|
|
87
|
+
if self._running:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
self._running = True
|
|
91
|
+
self._stop_event.clear()
|
|
92
|
+
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
|
|
93
|
+
self._thread.start()
|
|
94
|
+
logger.info(
|
|
95
|
+
"[%s] Started adaptive sync (path: %s)", self.name, self.remote_path
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
"""Stop background syncing"""
|
|
100
|
+
if not self._running:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self._running = False
|
|
104
|
+
self._stop_event.set()
|
|
105
|
+
|
|
106
|
+
if self._thread:
|
|
107
|
+
self._thread.join(timeout=2.0)
|
|
108
|
+
self._thread = None
|
|
109
|
+
|
|
110
|
+
logger.info("[%s] Stopped adaptive sync", self.name)
|
|
111
|
+
|
|
112
|
+
def sync_now(self) -> Optional[Path]:
|
|
113
|
+
"""Perform an immediate sync (blocking)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Local path if successful, None otherwise
|
|
117
|
+
"""
|
|
118
|
+
return self._do_sync()
|
|
119
|
+
|
|
120
|
+
def _sync_loop(self) -> None:
|
|
121
|
+
"""Background sync loop"""
|
|
122
|
+
# Do initial sync immediately
|
|
123
|
+
self._do_sync()
|
|
124
|
+
|
|
125
|
+
while self._running:
|
|
126
|
+
# Wait for interval or stop signal
|
|
127
|
+
if self._stop_event.wait(timeout=self._interval):
|
|
128
|
+
break # Stop requested
|
|
129
|
+
|
|
130
|
+
if not self._running:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
self._do_sync()
|
|
134
|
+
|
|
135
|
+
def _do_sync(self) -> Optional[Path]:
|
|
136
|
+
"""Perform a single sync operation"""
|
|
137
|
+
try:
|
|
138
|
+
self._syncing = True
|
|
139
|
+
if self.on_sync_start:
|
|
140
|
+
self.on_sync_start()
|
|
141
|
+
|
|
142
|
+
logger.info("[%s] Starting rsync for %s...", self.name, self.remote_path)
|
|
143
|
+
start_time = time.time()
|
|
144
|
+
local_path = self.sync_func(self.remote_path)
|
|
145
|
+
logger.info("[%s] sync_func returned: %s", self.name, local_path)
|
|
146
|
+
sync_duration = time.time() - start_time
|
|
147
|
+
|
|
148
|
+
if local_path:
|
|
149
|
+
self._local_path = local_path
|
|
150
|
+
|
|
151
|
+
# Check for changes
|
|
152
|
+
has_changes = self._check_for_changes(local_path)
|
|
153
|
+
|
|
154
|
+
# Adjust interval based on changes
|
|
155
|
+
if has_changes:
|
|
156
|
+
# Changes detected - sync more frequently
|
|
157
|
+
self._interval = max(
|
|
158
|
+
MIN_SYNC_INTERVAL,
|
|
159
|
+
self._interval * SPEEDUP_FACTOR,
|
|
160
|
+
)
|
|
161
|
+
# But ensure we don't sync faster than the sync takes
|
|
162
|
+
self._interval = max(self._interval, sync_duration * 2)
|
|
163
|
+
logger.info(
|
|
164
|
+
"[%s] Rsync completed in %.1fs (changes detected), "
|
|
165
|
+
"next sync in %.1fs",
|
|
166
|
+
self.name,
|
|
167
|
+
sync_duration,
|
|
168
|
+
self._interval,
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
# No changes - sync less frequently
|
|
172
|
+
self._interval = min(
|
|
173
|
+
MAX_SYNC_INTERVAL,
|
|
174
|
+
self._interval * SLOWDOWN_FACTOR,
|
|
175
|
+
)
|
|
176
|
+
logger.info(
|
|
177
|
+
"[%s] Rsync completed in %.1fs (no changes), "
|
|
178
|
+
"next sync in %.1fs",
|
|
179
|
+
self.name,
|
|
180
|
+
sync_duration,
|
|
181
|
+
self._interval,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if self.on_sync_complete:
|
|
185
|
+
self.on_sync_complete(local_path)
|
|
186
|
+
|
|
187
|
+
return local_path
|
|
188
|
+
else:
|
|
189
|
+
logger.warning("[%s] Rsync returned no path", self.name)
|
|
190
|
+
if self.on_sync_error:
|
|
191
|
+
self.on_sync_error("Sync returned no path")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning("[%s] Rsync failed: %s", self.name, e)
|
|
196
|
+
# On error, slow down to avoid hammering
|
|
197
|
+
self._interval = min(MAX_SYNC_INTERVAL, self._interval * 2)
|
|
198
|
+
logger.info(
|
|
199
|
+
"[%s] Next sync in %.1fs (after error)", self.name, self._interval
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if self.on_sync_error:
|
|
203
|
+
self.on_sync_error(str(e))
|
|
204
|
+
|
|
205
|
+
return None
|
|
206
|
+
finally:
|
|
207
|
+
self._syncing = False
|
|
208
|
+
|
|
209
|
+
def _check_for_changes(self, local_path: Path) -> bool:
|
|
210
|
+
"""Check if any files in the synced directory have changed
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True if changes detected, False otherwise
|
|
214
|
+
"""
|
|
215
|
+
has_changes = False
|
|
216
|
+
current_mtimes: dict[str, float] = {}
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# Check all files in the directory
|
|
220
|
+
if local_path.is_dir():
|
|
221
|
+
for file_path in local_path.rglob("*"):
|
|
222
|
+
if file_path.is_file():
|
|
223
|
+
key = str(file_path)
|
|
224
|
+
try:
|
|
225
|
+
mtime = file_path.stat().st_mtime
|
|
226
|
+
current_mtimes[key] = mtime
|
|
227
|
+
|
|
228
|
+
if key not in self._last_mtimes:
|
|
229
|
+
# New file
|
|
230
|
+
has_changes = True
|
|
231
|
+
elif self._last_mtimes[key] != mtime:
|
|
232
|
+
# File modified
|
|
233
|
+
has_changes = True
|
|
234
|
+
except OSError:
|
|
235
|
+
pass
|
|
236
|
+
elif local_path.is_file():
|
|
237
|
+
key = str(local_path)
|
|
238
|
+
try:
|
|
239
|
+
mtime = local_path.stat().st_mtime
|
|
240
|
+
current_mtimes[key] = mtime
|
|
241
|
+
|
|
242
|
+
if key not in self._last_mtimes:
|
|
243
|
+
has_changes = True
|
|
244
|
+
elif self._last_mtimes[key] != mtime:
|
|
245
|
+
has_changes = True
|
|
246
|
+
except OSError:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# Check for deleted files
|
|
250
|
+
if set(self._last_mtimes.keys()) != set(current_mtimes.keys()):
|
|
251
|
+
has_changes = True
|
|
252
|
+
|
|
253
|
+
self._last_mtimes = current_mtimes
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.warning("Error checking for changes: %s", e)
|
|
257
|
+
|
|
258
|
+
return has_changes
|
|
259
|
+
|
|
260
|
+
def __enter__(self):
|
|
261
|
+
self.start()
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def __exit__(self, *args):
|
|
265
|
+
self.stop()
|