s3ui 1.0.0__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.
- s3ui/__init__.py +1 -0
- s3ui/app.py +56 -0
- s3ui/constants.py +39 -0
- s3ui/core/__init__.py +0 -0
- s3ui/core/cost.py +218 -0
- s3ui/core/credentials.py +165 -0
- s3ui/core/download_worker.py +260 -0
- s3ui/core/errors.py +104 -0
- s3ui/core/listing_cache.py +178 -0
- s3ui/core/s3_client.py +358 -0
- s3ui/core/stats.py +128 -0
- s3ui/core/transfers.py +281 -0
- s3ui/core/upload_worker.py +311 -0
- s3ui/db/__init__.py +0 -0
- s3ui/db/database.py +143 -0
- s3ui/db/migrations/001_initial.sql +114 -0
- s3ui/logging_setup.py +18 -0
- s3ui/main_window.py +969 -0
- s3ui/models/__init__.py +0 -0
- s3ui/models/s3_objects.py +295 -0
- s3ui/models/transfer_model.py +282 -0
- s3ui/resources/__init__.py +0 -0
- s3ui/resources/s3ui.png +0 -0
- s3ui/ui/__init__.py +0 -0
- s3ui/ui/breadcrumb_bar.py +150 -0
- s3ui/ui/confirm_delete.py +60 -0
- s3ui/ui/cost_dialog.py +163 -0
- s3ui/ui/get_info.py +50 -0
- s3ui/ui/local_pane.py +226 -0
- s3ui/ui/name_conflict.py +68 -0
- s3ui/ui/s3_pane.py +547 -0
- s3ui/ui/settings_dialog.py +328 -0
- s3ui/ui/setup_wizard.py +462 -0
- s3ui/ui/stats_dialog.py +162 -0
- s3ui/ui/transfer_panel.py +153 -0
- s3ui-1.0.0.dist-info/METADATA +118 -0
- s3ui-1.0.0.dist-info/RECORD +40 -0
- s3ui-1.0.0.dist-info/WHEEL +4 -0
- s3ui-1.0.0.dist-info/entry_points.txt +2 -0
- s3ui-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Download worker — handles single and ranged-GET downloads as a QRunnable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import random
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from PyQt6.QtCore import QObject, QRunnable, pyqtSignal
|
|
13
|
+
|
|
14
|
+
from s3ui.constants import DEFAULT_PART_SIZE, MAX_RETRY_ATTEMPTS, MULTIPART_THRESHOLD
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from s3ui.core.s3_client import S3Client
|
|
18
|
+
from s3ui.db.database import Database
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("s3ui.download_worker")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DownloadWorkerSignals(QObject):
|
|
24
|
+
progress = pyqtSignal(int, int, int) # transfer_id, bytes_done, total
|
|
25
|
+
speed = pyqtSignal(int, float) # transfer_id, bytes_per_sec
|
|
26
|
+
finished = pyqtSignal(int) # transfer_id
|
|
27
|
+
failed = pyqtSignal(int, str, str) # transfer_id, user_msg, detail
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DownloadWorker(QRunnable):
|
|
31
|
+
"""Downloads a file from S3, with ranged GET and resume support."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
transfer_id: int,
|
|
36
|
+
s3_client: S3Client,
|
|
37
|
+
db: Database,
|
|
38
|
+
bucket: str,
|
|
39
|
+
pause_event: threading.Event,
|
|
40
|
+
cancel_event: threading.Event,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.setAutoDelete(True)
|
|
44
|
+
self.signals = DownloadWorkerSignals()
|
|
45
|
+
self.transfer_id = transfer_id
|
|
46
|
+
self._s3 = s3_client
|
|
47
|
+
self._db = db
|
|
48
|
+
self._bucket = bucket
|
|
49
|
+
self._pause = pause_event
|
|
50
|
+
self._cancel = cancel_event
|
|
51
|
+
|
|
52
|
+
self._speed_window: list[tuple[float, int]] = []
|
|
53
|
+
self._last_speed_emit = 0.0
|
|
54
|
+
|
|
55
|
+
def run(self) -> None:
|
|
56
|
+
try:
|
|
57
|
+
self._do_download()
|
|
58
|
+
except Exception as e:
|
|
59
|
+
import traceback
|
|
60
|
+
|
|
61
|
+
logger.error("Download %d failed: %s", self.transfer_id, e)
|
|
62
|
+
try:
|
|
63
|
+
self._mark_failed(str(e))
|
|
64
|
+
except Exception:
|
|
65
|
+
logger.exception("Failed to mark download %d as failed", self.transfer_id)
|
|
66
|
+
self.signals.failed.emit(self.transfer_id, str(e), traceback.format_exc())
|
|
67
|
+
|
|
68
|
+
def _do_download(self) -> None:
|
|
69
|
+
row = self._db.fetchone("SELECT * FROM transfers WHERE id = ?", (self.transfer_id,))
|
|
70
|
+
if not row:
|
|
71
|
+
self.signals.failed.emit(self.transfer_id, "Transfer record not found.", "")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
local_path = Path(row["local_path"])
|
|
75
|
+
object_key = row["object_key"]
|
|
76
|
+
|
|
77
|
+
# Validate destination directory
|
|
78
|
+
if not local_path.parent.exists():
|
|
79
|
+
self._mark_failed("Destination directory does not exist.")
|
|
80
|
+
self.signals.failed.emit(
|
|
81
|
+
self.transfer_id,
|
|
82
|
+
"Destination directory does not exist.",
|
|
83
|
+
str(local_path.parent),
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self._db.execute(
|
|
88
|
+
"UPDATE transfers SET status = 'in_progress', updated_at = datetime('now') "
|
|
89
|
+
"WHERE id = ?",
|
|
90
|
+
(self.transfer_id,),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Get object metadata
|
|
94
|
+
item = self._s3.head_object(self._bucket, object_key)
|
|
95
|
+
total_size = item.size or 0
|
|
96
|
+
|
|
97
|
+
self._db.execute(
|
|
98
|
+
"UPDATE transfers SET total_bytes = ? WHERE id = ?",
|
|
99
|
+
(total_size, self.transfer_id),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
temp_path = local_path.parent / f".s3ui-download-{self.transfer_id}.tmp"
|
|
103
|
+
|
|
104
|
+
if total_size < MULTIPART_THRESHOLD:
|
|
105
|
+
self._single_download(object_key, local_path, temp_path, total_size)
|
|
106
|
+
else:
|
|
107
|
+
self._ranged_download(object_key, local_path, temp_path, total_size)
|
|
108
|
+
|
|
109
|
+
def _single_download(self, key: str, final_path: Path, temp_path: Path, total: int) -> None:
|
|
110
|
+
body = self._s3.get_object(self._bucket, key)
|
|
111
|
+
data = body.read()
|
|
112
|
+
temp_path.write_bytes(data)
|
|
113
|
+
temp_path.rename(final_path)
|
|
114
|
+
self._complete(total)
|
|
115
|
+
|
|
116
|
+
def _ranged_download(self, key: str, final_path: Path, temp_path: Path, total: int) -> None:
|
|
117
|
+
chunk_size = DEFAULT_PART_SIZE
|
|
118
|
+
|
|
119
|
+
# Resume from existing temp file
|
|
120
|
+
offset = 0
|
|
121
|
+
if temp_path.exists():
|
|
122
|
+
offset = temp_path.stat().st_size
|
|
123
|
+
self._db.execute(
|
|
124
|
+
"UPDATE transfers SET transferred = ? WHERE id = ?",
|
|
125
|
+
(offset, self.transfer_id),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
cancelled = False
|
|
129
|
+
paused = False
|
|
130
|
+
with open(temp_path, "ab") as f:
|
|
131
|
+
while offset < total:
|
|
132
|
+
if self._cancel.is_set():
|
|
133
|
+
cancelled = True
|
|
134
|
+
break
|
|
135
|
+
if self._pause.is_set():
|
|
136
|
+
paused = True
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
end = min(offset + chunk_size - 1, total - 1)
|
|
140
|
+
range_header = f"bytes={offset}-{end}"
|
|
141
|
+
|
|
142
|
+
data = self._download_chunk_with_retry(key, range_header)
|
|
143
|
+
if data is None:
|
|
144
|
+
return # failed signal already emitted
|
|
145
|
+
|
|
146
|
+
f.write(data)
|
|
147
|
+
offset += len(data)
|
|
148
|
+
|
|
149
|
+
self._db.execute(
|
|
150
|
+
"UPDATE transfers SET transferred = ?, "
|
|
151
|
+
"updated_at = datetime('now') WHERE id = ?",
|
|
152
|
+
(offset, self.transfer_id),
|
|
153
|
+
)
|
|
154
|
+
self.signals.progress.emit(self.transfer_id, offset, total)
|
|
155
|
+
self._update_speed(len(data))
|
|
156
|
+
|
|
157
|
+
if cancelled:
|
|
158
|
+
self._do_cancel(temp_path)
|
|
159
|
+
return
|
|
160
|
+
if paused:
|
|
161
|
+
self._do_pause(offset)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Verify size
|
|
165
|
+
actual_size = temp_path.stat().st_size
|
|
166
|
+
if actual_size != total:
|
|
167
|
+
msg = f"Size mismatch: expected {total}, got {actual_size}"
|
|
168
|
+
self._mark_failed(msg)
|
|
169
|
+
self.signals.failed.emit(self.transfer_id, msg, "")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# Atomic rename
|
|
173
|
+
temp_path.rename(final_path)
|
|
174
|
+
self._complete(total)
|
|
175
|
+
|
|
176
|
+
def _download_chunk_with_retry(self, key: str, range_header: str) -> bytes | None:
|
|
177
|
+
for attempt in range(MAX_RETRY_ATTEMPTS):
|
|
178
|
+
try:
|
|
179
|
+
body = self._s3.get_object(self._bucket, key, range_header)
|
|
180
|
+
return body.read()
|
|
181
|
+
except Exception as e:
|
|
182
|
+
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
|
183
|
+
delay = _backoff_delay(attempt)
|
|
184
|
+
logger.warning(
|
|
185
|
+
"Download chunk attempt %d failed, retrying in %.1fs: %s",
|
|
186
|
+
attempt + 1,
|
|
187
|
+
delay,
|
|
188
|
+
e,
|
|
189
|
+
)
|
|
190
|
+
time.sleep(delay)
|
|
191
|
+
else:
|
|
192
|
+
self._mark_failed(str(e))
|
|
193
|
+
self.signals.failed.emit(
|
|
194
|
+
self.transfer_id,
|
|
195
|
+
f"Download failed after {MAX_RETRY_ATTEMPTS} attempts.",
|
|
196
|
+
str(e),
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def _complete(self, total: int) -> None:
|
|
201
|
+
self._db.execute(
|
|
202
|
+
"UPDATE transfers SET status = 'completed', transferred = ?, "
|
|
203
|
+
"updated_at = datetime('now') WHERE id = ?",
|
|
204
|
+
(total, self.transfer_id),
|
|
205
|
+
)
|
|
206
|
+
self.signals.progress.emit(self.transfer_id, total, total)
|
|
207
|
+
self.signals.finished.emit(self.transfer_id)
|
|
208
|
+
logger.info("Download %d completed", self.transfer_id)
|
|
209
|
+
|
|
210
|
+
def _mark_failed(self, msg: str) -> None:
|
|
211
|
+
self._db.execute(
|
|
212
|
+
"UPDATE transfers SET status = 'failed', error_message = ?, "
|
|
213
|
+
"updated_at = datetime('now') WHERE id = ?",
|
|
214
|
+
(msg, self.transfer_id),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _do_cancel(self, temp_path: Path) -> None:
|
|
218
|
+
if temp_path.exists():
|
|
219
|
+
try:
|
|
220
|
+
temp_path.unlink()
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.exception(
|
|
223
|
+
"Failed to remove temp file for transfer %d: %s",
|
|
224
|
+
self.transfer_id,
|
|
225
|
+
temp_path,
|
|
226
|
+
)
|
|
227
|
+
self._db.execute(
|
|
228
|
+
"UPDATE transfers SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?",
|
|
229
|
+
(self.transfer_id,),
|
|
230
|
+
)
|
|
231
|
+
logger.info("Download %d cancelled", self.transfer_id)
|
|
232
|
+
|
|
233
|
+
def _do_pause(self, offset: int) -> None:
|
|
234
|
+
self._db.execute(
|
|
235
|
+
"UPDATE transfers SET status = 'paused', transferred = ?, "
|
|
236
|
+
"updated_at = datetime('now') WHERE id = ?",
|
|
237
|
+
(offset, self.transfer_id),
|
|
238
|
+
)
|
|
239
|
+
logger.info("Download %d paused at offset %d", self.transfer_id, offset)
|
|
240
|
+
|
|
241
|
+
def _update_speed(self, chunk_bytes: int) -> None:
|
|
242
|
+
now = time.monotonic()
|
|
243
|
+
self._speed_window.append((now, chunk_bytes))
|
|
244
|
+
self._speed_window = [(t, b) for t, b in self._speed_window if now - t <= 3.0]
|
|
245
|
+
if now - self._last_speed_emit >= 0.5 and self._speed_window:
|
|
246
|
+
window_time = now - self._speed_window[0][0]
|
|
247
|
+
if window_time > 0:
|
|
248
|
+
total_bytes = sum(b for _, b in self._speed_window)
|
|
249
|
+
bps = total_bytes / window_time
|
|
250
|
+
self.signals.speed.emit(self.transfer_id, bps)
|
|
251
|
+
self._last_speed_emit = now
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _backoff_delay(attempt: int) -> float:
|
|
255
|
+
"""Exponential backoff with jitter."""
|
|
256
|
+
if attempt == 0:
|
|
257
|
+
return 0.0
|
|
258
|
+
base = 4 ** (attempt - 1)
|
|
259
|
+
jitter_max = base * 0.5
|
|
260
|
+
return base + random.uniform(0, jitter_max)
|
s3ui/core/errors.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Maps boto3/botocore exceptions to plain-language error messages."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("s3ui.errors")
|
|
6
|
+
|
|
7
|
+
# Maps AWS error codes to (user-facing message, suggestion)
|
|
8
|
+
ERROR_MESSAGES: dict[str, tuple[str, str]] = {
|
|
9
|
+
"InvalidAccessKeyId": (
|
|
10
|
+
"Invalid access key.",
|
|
11
|
+
"Check that your Access Key ID is correct in Settings.",
|
|
12
|
+
),
|
|
13
|
+
"SignatureDoesNotMatch": (
|
|
14
|
+
"Invalid secret key.",
|
|
15
|
+
"Check that your Secret Access Key is correct in Settings.",
|
|
16
|
+
),
|
|
17
|
+
"AccessDenied": (
|
|
18
|
+
"Access denied.",
|
|
19
|
+
"Your AWS credentials don't have permission for this action. Check your IAM policy.",
|
|
20
|
+
),
|
|
21
|
+
"NoSuchBucket": (
|
|
22
|
+
"Bucket not found.",
|
|
23
|
+
"The bucket may have been deleted or you may have a typo in the name.",
|
|
24
|
+
),
|
|
25
|
+
"NoSuchKey": (
|
|
26
|
+
"File not found.",
|
|
27
|
+
"The file may have been deleted or moved by someone else.",
|
|
28
|
+
),
|
|
29
|
+
"BucketAlreadyOwnedByYou": (
|
|
30
|
+
"You already own this bucket.",
|
|
31
|
+
"",
|
|
32
|
+
),
|
|
33
|
+
"BucketNotEmpty": (
|
|
34
|
+
"Bucket is not empty.",
|
|
35
|
+
"Delete all files in the bucket before deleting it.",
|
|
36
|
+
),
|
|
37
|
+
"EntityTooLarge": (
|
|
38
|
+
"File is too large for a single upload.",
|
|
39
|
+
"This shouldn't happen — the app should use multipart upload. Please report this bug.",
|
|
40
|
+
),
|
|
41
|
+
"SlowDown": (
|
|
42
|
+
"S3 is asking us to slow down.",
|
|
43
|
+
"Too many requests. The app will retry automatically.",
|
|
44
|
+
),
|
|
45
|
+
"ServiceUnavailable": (
|
|
46
|
+
"S3 is temporarily unavailable.",
|
|
47
|
+
"Try again in a few moments.",
|
|
48
|
+
),
|
|
49
|
+
"InternalError": (
|
|
50
|
+
"S3 encountered an internal error.",
|
|
51
|
+
"Try again in a few moments.",
|
|
52
|
+
),
|
|
53
|
+
"RequestTimeout": (
|
|
54
|
+
"The request timed out.",
|
|
55
|
+
"Check your network connection and try again.",
|
|
56
|
+
),
|
|
57
|
+
"ExpiredToken": (
|
|
58
|
+
"Your credentials have expired.",
|
|
59
|
+
"Update your credentials in Settings.",
|
|
60
|
+
),
|
|
61
|
+
"InvalidBucketName": (
|
|
62
|
+
"Invalid bucket name.",
|
|
63
|
+
"Bucket names must be 3-63 characters, lowercase letters, numbers, and hyphens.",
|
|
64
|
+
),
|
|
65
|
+
"KeyTooLongError": (
|
|
66
|
+
"File name is too long.",
|
|
67
|
+
"S3 keys can be at most 1024 bytes.",
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def translate_error(exc: Exception) -> tuple[str, str]:
|
|
73
|
+
"""Translate a boto3 exception to (user_message, raw_detail).
|
|
74
|
+
|
|
75
|
+
Returns a tuple of (plain-language message for the user, raw error string for
|
|
76
|
+
the "Show Details" expander).
|
|
77
|
+
"""
|
|
78
|
+
raw_detail = str(exc)
|
|
79
|
+
|
|
80
|
+
# botocore ClientError
|
|
81
|
+
if hasattr(exc, "response"):
|
|
82
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
83
|
+
if code in ERROR_MESSAGES:
|
|
84
|
+
user_msg, suggestion = ERROR_MESSAGES[code]
|
|
85
|
+
if suggestion:
|
|
86
|
+
user_msg = f"{user_msg} {suggestion}"
|
|
87
|
+
return user_msg, raw_detail
|
|
88
|
+
# Unknown AWS error code
|
|
89
|
+
message = exc.response.get("Error", {}).get("Message", "")
|
|
90
|
+
return f"AWS error: {message}" if message else "An AWS error occurred.", raw_detail
|
|
91
|
+
|
|
92
|
+
# Connection errors
|
|
93
|
+
err_type = type(exc).__name__
|
|
94
|
+
if "ConnectionError" in err_type or "EndpointConnectionError" in err_type:
|
|
95
|
+
return (
|
|
96
|
+
"Could not connect to S3. Check your network connection and try again.",
|
|
97
|
+
raw_detail,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if "ReadTimeoutError" in err_type or "ConnectTimeoutError" in err_type:
|
|
101
|
+
return "The connection timed out. Check your network connection.", raw_detail
|
|
102
|
+
|
|
103
|
+
# Fallback
|
|
104
|
+
return "An unexpected error occurred.", raw_detail
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Stale-while-revalidate listing cache for S3 prefix listings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from s3ui.constants import LISTING_CACHE_MAX_ENTRIES, LISTING_CACHE_STALE_SECONDS
|
|
13
|
+
from s3ui.models.s3_objects import S3Item
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("s3ui.listing_cache")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CachedListing:
|
|
20
|
+
"""A single cached listing result."""
|
|
21
|
+
|
|
22
|
+
prefix: str
|
|
23
|
+
items: list[S3Item]
|
|
24
|
+
fetched_at: float # monotonic time
|
|
25
|
+
dirty: bool = False
|
|
26
|
+
mutation_counter: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ListingCache:
|
|
30
|
+
"""LRU cache for S3 prefix listings with mutation tracking.
|
|
31
|
+
|
|
32
|
+
Thread-safe: all access is protected by a lock.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
max_entries: int = LISTING_CACHE_MAX_ENTRIES,
|
|
38
|
+
stale_seconds: float = LISTING_CACHE_STALE_SECONDS,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._max_entries = max_entries
|
|
41
|
+
self._stale_seconds = stale_seconds
|
|
42
|
+
self._cache: OrderedDict[str, CachedListing] = OrderedDict()
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
def get(self, prefix: str) -> CachedListing | None:
|
|
46
|
+
"""Get a cached listing, promoting it to MRU. Returns None on miss."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
entry = self._cache.get(prefix)
|
|
49
|
+
if entry is not None:
|
|
50
|
+
self._cache.move_to_end(prefix)
|
|
51
|
+
return entry
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def put(self, prefix: str, items: list[S3Item]) -> None:
|
|
55
|
+
"""Store a listing, evicting LRU if over capacity."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
if prefix in self._cache:
|
|
58
|
+
existing = self._cache[prefix]
|
|
59
|
+
existing.items = list(items)
|
|
60
|
+
existing.fetched_at = time.monotonic()
|
|
61
|
+
existing.dirty = False
|
|
62
|
+
# Don't reset mutation_counter — revalidation handles that
|
|
63
|
+
self._cache.move_to_end(prefix)
|
|
64
|
+
else:
|
|
65
|
+
self._cache[prefix] = CachedListing(
|
|
66
|
+
prefix=prefix,
|
|
67
|
+
items=list(items),
|
|
68
|
+
fetched_at=time.monotonic(),
|
|
69
|
+
)
|
|
70
|
+
self._evict_if_needed()
|
|
71
|
+
|
|
72
|
+
def invalidate(self, prefix: str) -> bool:
|
|
73
|
+
"""Remove one entry. Returns True if it existed."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
if prefix in self._cache:
|
|
76
|
+
del self._cache[prefix]
|
|
77
|
+
return True
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def invalidate_all(self) -> None:
|
|
81
|
+
"""Clear the entire cache."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._cache.clear()
|
|
84
|
+
|
|
85
|
+
def is_stale(self, prefix: str) -> bool:
|
|
86
|
+
"""True if entry is missing or older than stale threshold."""
|
|
87
|
+
with self._lock:
|
|
88
|
+
entry = self._cache.get(prefix)
|
|
89
|
+
if entry is None:
|
|
90
|
+
return True
|
|
91
|
+
age = time.monotonic() - entry.fetched_at
|
|
92
|
+
return age > self._stale_seconds
|
|
93
|
+
|
|
94
|
+
def apply_mutation(self, prefix: str, fn: Callable[[list[S3Item]], None]) -> bool:
|
|
95
|
+
"""Apply a mutation function to a cached listing's items.
|
|
96
|
+
|
|
97
|
+
Sets dirty=True and increments mutation_counter.
|
|
98
|
+
Returns False if prefix is not cached.
|
|
99
|
+
"""
|
|
100
|
+
with self._lock:
|
|
101
|
+
entry = self._cache.get(prefix)
|
|
102
|
+
if entry is None:
|
|
103
|
+
return False
|
|
104
|
+
fn(entry.items)
|
|
105
|
+
entry.dirty = True
|
|
106
|
+
entry.mutation_counter += 1
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def get_mutation_counter(self, prefix: str) -> int:
|
|
110
|
+
"""Returns current mutation counter for a prefix. 0 if not cached."""
|
|
111
|
+
with self._lock:
|
|
112
|
+
entry = self._cache.get(prefix)
|
|
113
|
+
if entry is None:
|
|
114
|
+
return 0
|
|
115
|
+
return entry.mutation_counter
|
|
116
|
+
|
|
117
|
+
def safe_revalidate(
|
|
118
|
+
self,
|
|
119
|
+
prefix: str,
|
|
120
|
+
new_items: list[S3Item],
|
|
121
|
+
counter_at_fetch_start: int,
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Apply background revalidation results safely.
|
|
124
|
+
|
|
125
|
+
If mutation_counter matches counter_at_fetch_start, does a standard replace.
|
|
126
|
+
If mutations happened since fetch started, merges: preserves optimistic
|
|
127
|
+
additions while incorporating external changes.
|
|
128
|
+
|
|
129
|
+
Returns True if the cache was updated.
|
|
130
|
+
"""
|
|
131
|
+
with self._lock:
|
|
132
|
+
entry = self._cache.get(prefix)
|
|
133
|
+
if entry is None:
|
|
134
|
+
# Cache was cleared while we were fetching — just store the result
|
|
135
|
+
self._cache[prefix] = CachedListing(
|
|
136
|
+
prefix=prefix,
|
|
137
|
+
items=list(new_items),
|
|
138
|
+
fetched_at=time.monotonic(),
|
|
139
|
+
)
|
|
140
|
+
self._evict_if_needed()
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
if entry.mutation_counter == counter_at_fetch_start:
|
|
144
|
+
# No mutations since fetch started — safe to replace
|
|
145
|
+
entry.items = list(new_items)
|
|
146
|
+
entry.fetched_at = time.monotonic()
|
|
147
|
+
entry.dirty = False
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
# Mutations happened — merge strategy:
|
|
151
|
+
# Keep items that exist in new_items (server truth)
|
|
152
|
+
# Also keep items that were added by optimistic mutations
|
|
153
|
+
# (items in current cache but NOT in the old server state)
|
|
154
|
+
new_keys = {item.key for item in new_items}
|
|
155
|
+
|
|
156
|
+
# Optimistic items: in current cache but not from server
|
|
157
|
+
optimistic = [item for item in entry.items if item.key not in new_keys]
|
|
158
|
+
|
|
159
|
+
# Build merged list: server items + optimistic items
|
|
160
|
+
merged = list(new_items) + optimistic
|
|
161
|
+
|
|
162
|
+
entry.items = merged
|
|
163
|
+
entry.fetched_at = time.monotonic()
|
|
164
|
+
entry.dirty = bool(optimistic) # still dirty if we have optimistic items
|
|
165
|
+
# Don't reset mutation_counter — it tracks total mutations
|
|
166
|
+
logger.debug(
|
|
167
|
+
"Merged revalidation for '%s': %d server + %d optimistic items",
|
|
168
|
+
prefix,
|
|
169
|
+
len(new_items),
|
|
170
|
+
len(optimistic),
|
|
171
|
+
)
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def _evict_if_needed(self) -> None:
|
|
175
|
+
"""Evict LRU entries if over capacity. Must be called with lock held."""
|
|
176
|
+
while len(self._cache) > self._max_entries:
|
|
177
|
+
evicted_key, _ = self._cache.popitem(last=False)
|
|
178
|
+
logger.debug("Evicted cache entry: '%s'", evicted_key)
|