fleet-python 0.2.120__tar.gz → 0.2.121__tar.gz
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.
- {fleet_python-0.2.120/fleet_python.egg-info → fleet_python-0.2.121}/PKG-INFO +1 -1
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/daemon.py +1 -3
- fleet_python-0.2.121/fleet/track/queue.py +192 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121/fleet_python.egg-info}/PKG-INFO +1 -1
- {fleet_python-0.2.120 → fleet_python-0.2.121}/pyproject.toml +1 -1
- fleet_python-0.2.120/fleet/track/queue.py +0 -158
- {fleet_python-0.2.120 → fleet_python-0.2.121}/LICENSE +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/README.md +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/diff_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/dsl_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/exampleResume.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_account.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_action_log.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_mcp_anthropic.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_mcp_openai.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_sync.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_task.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/example_verifier.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/export_tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/fetch_tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/gemini_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/import_tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/iterate_verifiers.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/json_tasks_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/nova_act_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/openai_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/openai_simple_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/query_builder_example.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/quickstart.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/task_bundle_editing/download_task.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/task_bundle_editing/launch_job.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/task_bundle_editing/upload_task.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/task_bundle_editing/validate_task.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/examples/test_cdp_logging.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/env/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/env/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/exceptions.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/global_client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/instance/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/instance/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/instance/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/models.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/api.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/browser.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/mcp.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/resources/sqlite.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/verifiers/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/verifiers/bundler.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_async/verifiers/verifier.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/_supabase.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/Dockerfile +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/agent.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/mcp/main.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/mcp_server/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/mcp_server/main.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/mcp_server/tools.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/requirements.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/gemini_cua/start.sh +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/orchestrator.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/types.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/agent/utils.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/auth.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/cli.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/config.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/env/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/env/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/eval/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/eval/uploader.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/exceptions.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/global_client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/instance/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/instance/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/instance/client.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/instance/models.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/models.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/proxy/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/proxy/proxy.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/proxy/whitelist.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/api.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/base.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/browser.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/mcp.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/resources/sqlite.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/tasks.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/api.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/cli.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/install.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/merkle.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/scrubber.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/sources.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/status.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/uploader.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/track/watcher.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/types.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/utils/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/utils/http_logging.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/utils/logging.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/utils/playwright.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/bundler.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/code.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/db.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/decorator.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/parse.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/sql_differ.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet/verifiers/verifier.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet_python.egg-info/SOURCES.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet_python.egg-info/dependency_links.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet_python.egg-info/entry_points.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet_python.egg-info/requires.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/fleet_python.egg-info/top_level.txt +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/scripts/fix_sync_imports.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/scripts/unasync.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/setup.cfg +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/__init__.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_app_method.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_expect_exactly.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_expect_only.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_instance_dispatch.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_sqlite_resource_dual_mode.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_sqlite_shared_memory_behavior.py +0 -0
- {fleet_python-0.2.120 → fleet_python-0.2.121}/tests/test_verifier_from_string.py +0 -0
|
@@ -98,7 +98,6 @@ class Daemon:
|
|
|
98
98
|
self._confirmed_map: dict[str, str] = {}
|
|
99
99
|
self._confirmed_lock = threading.Lock()
|
|
100
100
|
self._manifest_dirty = False # set when confirmed_map gains new entries
|
|
101
|
-
self._drain_lock = threading.Lock() # serialises claim_batch across threads
|
|
102
101
|
|
|
103
102
|
# ------------------------------------------------------------------ #
|
|
104
103
|
# Public entry point #
|
|
@@ -273,8 +272,7 @@ class Daemon:
|
|
|
273
272
|
if not self._pool:
|
|
274
273
|
return
|
|
275
274
|
|
|
276
|
-
|
|
277
|
-
items = self._queue.claim_batch(n=32)
|
|
275
|
+
items = self._queue.claim_batch(n=32)
|
|
278
276
|
if not items:
|
|
279
277
|
return
|
|
280
278
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""WAL-backed SQLite upload queue.
|
|
2
|
+
|
|
3
|
+
Survives daemon crashes. Items transition: pending → in_flight → done/failed.
|
|
4
|
+
Failed items are retried with exponential backoff.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from .merkle import STATE_DB, TRACK_DIR
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("fleet.track.queue")
|
|
20
|
+
|
|
21
|
+
MAX_ATTEMPTS = 10
|
|
22
|
+
BASE_BACKOFF_SECS = 0.5
|
|
23
|
+
MAX_BACKOFF_SECS = 1800 # 30 min
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _backoff(attempts: int) -> float:
|
|
27
|
+
return min(BASE_BACKOFF_SECS * (2 ** attempts), MAX_BACKOFF_SECS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QueueItem:
|
|
32
|
+
path: str # relative to $HOME
|
|
33
|
+
sha256: str
|
|
34
|
+
attempts: int
|
|
35
|
+
last_error: Optional[str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class UploadQueue:
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
TRACK_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._conn = self._open_conn()
|
|
43
|
+
|
|
44
|
+
def _open_conn(self) -> sqlite3.Connection:
|
|
45
|
+
conn = sqlite3.connect(STATE_DB, timeout=10, check_same_thread=False)
|
|
46
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
47
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
48
|
+
conn.execute("""
|
|
49
|
+
CREATE TABLE IF NOT EXISTS queue (
|
|
50
|
+
path TEXT NOT NULL,
|
|
51
|
+
sha256 TEXT NOT NULL,
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
53
|
+
attempts INTEGER DEFAULT 0,
|
|
54
|
+
last_error TEXT,
|
|
55
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
enqueued_at INTEGER NOT NULL,
|
|
57
|
+
updated_at INTEGER NOT NULL,
|
|
58
|
+
UNIQUE(path, sha256)
|
|
59
|
+
)
|
|
60
|
+
""")
|
|
61
|
+
conn.execute("CREATE INDEX IF NOT EXISTS queue_status ON queue(status, next_attempt_at)")
|
|
62
|
+
conn.commit()
|
|
63
|
+
|
|
64
|
+
result = conn.execute("PRAGMA integrity_check").fetchone()
|
|
65
|
+
if result[0] != "ok":
|
|
66
|
+
conn.close()
|
|
67
|
+
self._wipe_and_reinit()
|
|
68
|
+
return self._open_conn()
|
|
69
|
+
|
|
70
|
+
return conn
|
|
71
|
+
|
|
72
|
+
def _wipe_and_reinit(self) -> None:
|
|
73
|
+
"""Delete corrupt database files and start fresh. Queue items are lost but
|
|
74
|
+
the next reconcile will re-enqueue anything not yet on S3."""
|
|
75
|
+
log.warning("queue database is corrupt — wiping and reinitialising (next reconcile will re-upload missing files)")
|
|
76
|
+
for suffix in ("", "-shm", "-wal"):
|
|
77
|
+
p = Path(str(STATE_DB) + suffix)
|
|
78
|
+
if p.exists():
|
|
79
|
+
p.unlink(missing_ok=True)
|
|
80
|
+
|
|
81
|
+
def enqueue(self, path: str, sha256: str) -> None:
|
|
82
|
+
"""Add path to the queue. Idempotent: same (path, sha256) is a no-op."""
|
|
83
|
+
now = int(time.time())
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._conn.execute(
|
|
86
|
+
"""
|
|
87
|
+
INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
|
|
88
|
+
VALUES (?, ?, 'pending', 0, ?, ?)
|
|
89
|
+
""",
|
|
90
|
+
(path, sha256, now, now),
|
|
91
|
+
)
|
|
92
|
+
self._conn.commit()
|
|
93
|
+
|
|
94
|
+
def enqueue_batch(self, items: list[tuple[str, str]]) -> None:
|
|
95
|
+
now = int(time.time())
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._conn.executemany(
|
|
98
|
+
"""
|
|
99
|
+
INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
|
|
100
|
+
VALUES (?, ?, 'pending', 0, ?, ?)
|
|
101
|
+
""",
|
|
102
|
+
[(path, sha256, now, now) for path, sha256 in items],
|
|
103
|
+
)
|
|
104
|
+
self._conn.commit()
|
|
105
|
+
|
|
106
|
+
def claim_batch(self, n: int = 16) -> list[QueueItem]:
|
|
107
|
+
"""Atomically claim up to n pending items for upload."""
|
|
108
|
+
now = int(time.time())
|
|
109
|
+
with self._lock:
|
|
110
|
+
rows = self._conn.execute(
|
|
111
|
+
"""
|
|
112
|
+
SELECT path, sha256, attempts, last_error FROM queue
|
|
113
|
+
WHERE status = 'pending' AND next_attempt_at <= ?
|
|
114
|
+
ORDER BY enqueued_at ASC
|
|
115
|
+
LIMIT ?
|
|
116
|
+
""",
|
|
117
|
+
(now, n),
|
|
118
|
+
).fetchall()
|
|
119
|
+
|
|
120
|
+
if not rows:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
# Match on (path, sha256) — the natural key — so we never transition a
|
|
124
|
+
# row that wasn't in the SELECT result. Two rows can share a path with
|
|
125
|
+
# different sha256 values; path-only matching would orphan the extra row
|
|
126
|
+
# (set to in_flight with no upload attempted and no callback to resolve it).
|
|
127
|
+
placeholders = ",".join("(?,?)" for _ in rows)
|
|
128
|
+
pairs = [v for r in rows for v in (r[0], r[1])]
|
|
129
|
+
self._conn.execute(
|
|
130
|
+
f"UPDATE queue SET status = 'in_flight', updated_at = ? WHERE status = 'pending' AND (path, sha256) IN ({placeholders})",
|
|
131
|
+
[now] + pairs,
|
|
132
|
+
)
|
|
133
|
+
self._conn.commit()
|
|
134
|
+
|
|
135
|
+
return [QueueItem(r[0], r[1], r[2], r[3]) for r in rows]
|
|
136
|
+
|
|
137
|
+
def mark_done(self, path: str, sha256: str) -> None:
|
|
138
|
+
now = int(time.time())
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._conn.execute(
|
|
141
|
+
"UPDATE queue SET status = 'done', updated_at = ? WHERE path = ? AND sha256 = ?",
|
|
142
|
+
(now, path, sha256),
|
|
143
|
+
)
|
|
144
|
+
self._conn.commit()
|
|
145
|
+
|
|
146
|
+
def mark_failed(self, path: str, sha256: str, error: str) -> None:
|
|
147
|
+
now = int(time.time())
|
|
148
|
+
with self._lock:
|
|
149
|
+
row = self._conn.execute(
|
|
150
|
+
"SELECT attempts FROM queue WHERE path = ? AND sha256 = ?", (path, sha256)
|
|
151
|
+
).fetchone()
|
|
152
|
+
attempts = (row[0] if row else 0) + 1
|
|
153
|
+
status = "failed" if attempts >= MAX_ATTEMPTS else "pending"
|
|
154
|
+
next_attempt = now + int(_backoff(attempts))
|
|
155
|
+
self._conn.execute(
|
|
156
|
+
"""
|
|
157
|
+
UPDATE queue
|
|
158
|
+
SET status = ?, attempts = ?, last_error = ?, next_attempt_at = ?, updated_at = ?
|
|
159
|
+
WHERE path = ? AND sha256 = ?
|
|
160
|
+
""",
|
|
161
|
+
(status, attempts, error[:500], next_attempt, now, path, sha256),
|
|
162
|
+
)
|
|
163
|
+
self._conn.commit()
|
|
164
|
+
|
|
165
|
+
def reset_failed(self) -> int:
|
|
166
|
+
"""Re-queue permanently failed items (used by reconciliation loop)."""
|
|
167
|
+
now = int(time.time())
|
|
168
|
+
with self._lock:
|
|
169
|
+
cur = self._conn.execute(
|
|
170
|
+
"UPDATE queue SET status = 'pending', attempts = 0, next_attempt_at = 0, updated_at = ? WHERE status = 'failed'",
|
|
171
|
+
(now,),
|
|
172
|
+
)
|
|
173
|
+
self._conn.commit()
|
|
174
|
+
return cur.rowcount
|
|
175
|
+
|
|
176
|
+
def remove_done(self) -> None:
|
|
177
|
+
"""Purge successfully uploaded items older than 24h."""
|
|
178
|
+
cutoff = int(time.time()) - 86400
|
|
179
|
+
with self._lock:
|
|
180
|
+
self._conn.execute("DELETE FROM queue WHERE status = 'done' AND updated_at < ?", (cutoff,))
|
|
181
|
+
self._conn.commit()
|
|
182
|
+
|
|
183
|
+
def stats(self) -> dict[str, int]:
|
|
184
|
+
with self._lock:
|
|
185
|
+
rows = self._conn.execute(
|
|
186
|
+
"SELECT status, COUNT(*) FROM queue GROUP BY status"
|
|
187
|
+
).fetchall()
|
|
188
|
+
return {row[0]: row[1] for row in rows}
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
self._conn.close()
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
"""WAL-backed SQLite upload queue.
|
|
2
|
-
|
|
3
|
-
Survives daemon crashes. Items transition: pending → in_flight → done/failed.
|
|
4
|
-
Failed items are retried with exponential backoff.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import sqlite3
|
|
10
|
-
import time
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
from typing import Optional
|
|
14
|
-
|
|
15
|
-
from .merkle import STATE_DB, TRACK_DIR
|
|
16
|
-
|
|
17
|
-
MAX_ATTEMPTS = 10
|
|
18
|
-
BASE_BACKOFF_SECS = 0.5
|
|
19
|
-
MAX_BACKOFF_SECS = 1800 # 30 min
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _backoff(attempts: int) -> float:
|
|
23
|
-
return min(BASE_BACKOFF_SECS * (2 ** attempts), MAX_BACKOFF_SECS)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@dataclass
|
|
27
|
-
class QueueItem:
|
|
28
|
-
path: str # relative to $HOME
|
|
29
|
-
sha256: str
|
|
30
|
-
attempts: int
|
|
31
|
-
last_error: Optional[str]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class UploadQueue:
|
|
35
|
-
def __init__(self) -> None:
|
|
36
|
-
TRACK_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
-
self._conn = sqlite3.connect(STATE_DB, timeout=10, check_same_thread=False)
|
|
38
|
-
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
39
|
-
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
40
|
-
self._conn.execute("""
|
|
41
|
-
CREATE TABLE IF NOT EXISTS queue (
|
|
42
|
-
path TEXT NOT NULL,
|
|
43
|
-
sha256 TEXT NOT NULL,
|
|
44
|
-
status TEXT NOT NULL DEFAULT 'pending',
|
|
45
|
-
attempts INTEGER DEFAULT 0,
|
|
46
|
-
last_error TEXT,
|
|
47
|
-
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
48
|
-
enqueued_at INTEGER NOT NULL,
|
|
49
|
-
updated_at INTEGER NOT NULL,
|
|
50
|
-
UNIQUE(path, sha256)
|
|
51
|
-
)
|
|
52
|
-
""")
|
|
53
|
-
self._conn.execute("CREATE INDEX IF NOT EXISTS queue_status ON queue(status, next_attempt_at)")
|
|
54
|
-
self._conn.commit()
|
|
55
|
-
|
|
56
|
-
def enqueue(self, path: str, sha256: str) -> None:
|
|
57
|
-
"""Add path to the queue. Idempotent: same (path, sha256) is a no-op."""
|
|
58
|
-
now = int(time.time())
|
|
59
|
-
self._conn.execute(
|
|
60
|
-
"""
|
|
61
|
-
INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
|
|
62
|
-
VALUES (?, ?, 'pending', 0, ?, ?)
|
|
63
|
-
""",
|
|
64
|
-
(path, sha256, now, now),
|
|
65
|
-
)
|
|
66
|
-
self._conn.commit()
|
|
67
|
-
|
|
68
|
-
def enqueue_batch(self, items: list[tuple[str, str]]) -> None:
|
|
69
|
-
now = int(time.time())
|
|
70
|
-
self._conn.executemany(
|
|
71
|
-
"""
|
|
72
|
-
INSERT OR IGNORE INTO queue (path, sha256, status, next_attempt_at, enqueued_at, updated_at)
|
|
73
|
-
VALUES (?, ?, 'pending', 0, ?, ?)
|
|
74
|
-
""",
|
|
75
|
-
[(path, sha256, now, now) for path, sha256 in items],
|
|
76
|
-
)
|
|
77
|
-
self._conn.commit()
|
|
78
|
-
|
|
79
|
-
def claim_batch(self, n: int = 16) -> list[QueueItem]:
|
|
80
|
-
"""Atomically claim up to n pending items for upload."""
|
|
81
|
-
now = int(time.time())
|
|
82
|
-
rows = self._conn.execute(
|
|
83
|
-
"""
|
|
84
|
-
SELECT path, sha256, attempts, last_error FROM queue
|
|
85
|
-
WHERE status = 'pending' AND next_attempt_at <= ?
|
|
86
|
-
ORDER BY enqueued_at ASC
|
|
87
|
-
LIMIT ?
|
|
88
|
-
""",
|
|
89
|
-
(now, n),
|
|
90
|
-
).fetchall()
|
|
91
|
-
|
|
92
|
-
if not rows:
|
|
93
|
-
return []
|
|
94
|
-
|
|
95
|
-
# Match on (path, sha256) — the natural key — so we never transition a
|
|
96
|
-
# row that wasn't in the SELECT result. Two rows can share a path with
|
|
97
|
-
# different sha256 values; path-only matching would orphan the extra row
|
|
98
|
-
# (set to in_flight with no upload attempted and no callback to resolve it).
|
|
99
|
-
placeholders = ",".join("(?,?)" for _ in rows)
|
|
100
|
-
pairs = [v for r in rows for v in (r[0], r[1])]
|
|
101
|
-
self._conn.execute(
|
|
102
|
-
f"UPDATE queue SET status = 'in_flight', updated_at = ? WHERE status = 'pending' AND (path, sha256) IN ({placeholders})",
|
|
103
|
-
[now] + pairs,
|
|
104
|
-
)
|
|
105
|
-
self._conn.commit()
|
|
106
|
-
|
|
107
|
-
return [QueueItem(r[0], r[1], r[2], r[3]) for r in rows]
|
|
108
|
-
|
|
109
|
-
def mark_done(self, path: str, sha256: str) -> None:
|
|
110
|
-
now = int(time.time())
|
|
111
|
-
self._conn.execute(
|
|
112
|
-
"UPDATE queue SET status = 'done', updated_at = ? WHERE path = ? AND sha256 = ?",
|
|
113
|
-
(now, path, sha256),
|
|
114
|
-
)
|
|
115
|
-
self._conn.commit()
|
|
116
|
-
|
|
117
|
-
def mark_failed(self, path: str, sha256: str, error: str) -> None:
|
|
118
|
-
now = int(time.time())
|
|
119
|
-
row = self._conn.execute(
|
|
120
|
-
"SELECT attempts FROM queue WHERE path = ? AND sha256 = ?", (path, sha256)
|
|
121
|
-
).fetchone()
|
|
122
|
-
attempts = (row[0] if row else 0) + 1
|
|
123
|
-
status = "failed" if attempts >= MAX_ATTEMPTS else "pending"
|
|
124
|
-
next_attempt = now + int(_backoff(attempts))
|
|
125
|
-
self._conn.execute(
|
|
126
|
-
"""
|
|
127
|
-
UPDATE queue
|
|
128
|
-
SET status = ?, attempts = ?, last_error = ?, next_attempt_at = ?, updated_at = ?
|
|
129
|
-
WHERE path = ? AND sha256 = ?
|
|
130
|
-
""",
|
|
131
|
-
(status, attempts, error[:500], next_attempt, now, path, sha256),
|
|
132
|
-
)
|
|
133
|
-
self._conn.commit()
|
|
134
|
-
|
|
135
|
-
def reset_failed(self) -> int:
|
|
136
|
-
"""Re-queue permanently failed items (used by reconciliation loop)."""
|
|
137
|
-
now = int(time.time())
|
|
138
|
-
cur = self._conn.execute(
|
|
139
|
-
"UPDATE queue SET status = 'pending', attempts = 0, next_attempt_at = 0, updated_at = ? WHERE status = 'failed'",
|
|
140
|
-
(now,),
|
|
141
|
-
)
|
|
142
|
-
self._conn.commit()
|
|
143
|
-
return cur.rowcount
|
|
144
|
-
|
|
145
|
-
def remove_done(self) -> None:
|
|
146
|
-
"""Purge successfully uploaded items older than 24h."""
|
|
147
|
-
cutoff = int(time.time()) - 86400
|
|
148
|
-
self._conn.execute("DELETE FROM queue WHERE status = 'done' AND updated_at < ?", (cutoff,))
|
|
149
|
-
self._conn.commit()
|
|
150
|
-
|
|
151
|
-
def stats(self) -> dict[str, int]:
|
|
152
|
-
rows = self._conn.execute(
|
|
153
|
-
"SELECT status, COUNT(*) FROM queue GROUP BY status"
|
|
154
|
-
).fetchall()
|
|
155
|
-
return {row[0]: row[1] for row in rows}
|
|
156
|
-
|
|
157
|
-
def close(self) -> None:
|
|
158
|
-
self._conn.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|