pjpjq commited on
Commit
cceb89b
·
1 Parent(s): f6da647

fix(usage-keeper): 收紧HF快照轮转上传

Browse files
.env.example CHANGED
@@ -50,6 +50,19 @@ REQUEST_TIMEOUT=30s
50
  # Application work directory. SQLite database, logs, and backups are stored here by default. In Docker the runtime working directory is /, so ./data maps to /data; for binaries loaded with --env or package-local .env, ./data maps to a data directory next to the env file. Required: no. Default: ./data.
51
  WORK_DIR=./data
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # 应用日志级别。必填:否。默认值:info。
54
  # Application log level. Required: no. Default: info.
55
  LOG_LEVEL=info
 
50
  # Application work directory. SQLite database, logs, and backups are stored here by default. In Docker the runtime working directory is /, so ./data maps to /data; for binaries loaded with --env or package-local .env, ./data maps to a data directory next to the env file. Required: no. Default: ./data.
51
  WORK_DIR=./data
52
 
53
+ # Hugging Face Space SQLite 快照上传间隔。Space 默认每 60 秒上传一次;配合 rotate 只保留最近 KEEPER_HF_ROTATE_KEEP 个历史文件。
54
+ # Hugging Face Space SQLite snapshot upload interval. The Space default uploads every 60 seconds; rotation keeps only the latest KEEPER_HF_ROTATE_KEEP history files.
55
+ KEEPER_HF_UPLOAD_INTERVAL=60
56
+
57
+ # Hugging Face SQLite 快照轮转粒度和保留数量。默认 60 秒一个 history 文件、保留 48 个;如需更长恢复窗口可调大 KEEP。
58
+ # Hugging Face SQLite snapshot rotation bucket and retention count. Defaults to one history file per 60 seconds and keeps 48 files; increase KEEP for a longer restore window.
59
+ KEEPER_HF_ROTATE_INTERVAL=60
60
+ KEEPER_HF_ROTATE_KEEP=48
61
+
62
+ # 是否额外维护 KEEPER_HF_PATH 指向的最新 app.db。默认关闭,避免每分钟覆盖同一路径导致远端历史/LFS 膨胀;恢复会优先读 rotate manifest。
63
+ # Whether to also maintain the latest app.db at KEEPER_HF_PATH. Disabled by default to avoid growing remote history/LFS by overwriting the same path every minute; restore reads the rotate manifest first.
64
+ KEEPER_HF_WRITE_LATEST=0
65
+
66
  # 应用日志级别。必填:否。默认值:info。
67
  # Application log level. Required: no. Default: info.
68
  LOG_LEVEL=info
entrypoint.space.sh CHANGED
@@ -13,9 +13,10 @@ export AUTH_ENABLED="${AUTH_ENABLED:-true}"
13
  export KEEPER_HF_REPO_ID="${KEEPER_HF_REPO_ID:-pjpjq/daili-usage-state}"
14
  export KEEPER_HF_REPO_TYPE="${KEEPER_HF_REPO_TYPE:-dataset}"
15
  export KEEPER_HF_PATH="${KEEPER_HF_PATH:-usage-keeper/app.db}"
16
- export KEEPER_HF_ROTATE_INTERVAL="${KEEPER_HF_ROTATE_INTERVAL:-3600}"
17
  export KEEPER_HF_ROTATE_KEEP="${KEEPER_HF_ROTATE_KEEP:-48}"
18
- export KEEPER_HF_UPLOAD_INTERVAL="${KEEPER_HF_UPLOAD_INTERVAL:-300}"
 
19
 
20
  missing=""
21
  [ -n "${CPA_MANAGEMENT_KEY:-}" ] || missing="$missing CPA_MANAGEMENT_KEY"
 
13
  export KEEPER_HF_REPO_ID="${KEEPER_HF_REPO_ID:-pjpjq/daili-usage-state}"
14
  export KEEPER_HF_REPO_TYPE="${KEEPER_HF_REPO_TYPE:-dataset}"
15
  export KEEPER_HF_PATH="${KEEPER_HF_PATH:-usage-keeper/app.db}"
16
+ export KEEPER_HF_ROTATE_INTERVAL="${KEEPER_HF_ROTATE_INTERVAL:-60}"
17
  export KEEPER_HF_ROTATE_KEEP="${KEEPER_HF_ROTATE_KEEP:-48}"
18
+ export KEEPER_HF_WRITE_LATEST="${KEEPER_HF_WRITE_LATEST:-0}"
19
+ export KEEPER_HF_UPLOAD_INTERVAL="${KEEPER_HF_UPLOAD_INTERVAL:-60}"
20
 
21
  missing=""
22
  [ -n "${CPA_MANAGEMENT_KEY:-}" ] || missing="$missing CPA_MANAGEMENT_KEY"
hf_state_snapshot.py CHANGED
@@ -18,7 +18,7 @@ from huggingface_hub.errors import EntryNotFoundError, RepositoryNotFoundError
18
 
19
 
20
  ROTATION_MANIFEST_TYPE = "daili_usage_keeper_sqlite_rotation_v1"
21
- DEFAULT_ROTATE_INTERVAL_SECONDS = 3600
22
  DEFAULT_ROTATE_KEEP = 48
23
 
24
 
@@ -43,6 +43,13 @@ def env_int(name: str, default: int) -> int:
43
  return default
44
 
45
 
 
 
 
 
 
 
 
46
  def isoformat_utc(value: datetime) -> str:
47
  return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
48
 
@@ -126,10 +133,66 @@ def latest_snapshot_from_manifest(token: str, repo_id: str, path_in_repo: str) -
126
  return download_repo_file(token, repo_id, latest_path)
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def download_latest(token: str, repo_id: str, path_in_repo: str, local_path: Path) -> int:
130
- downloaded = download_repo_file(token, repo_id, path_in_repo)
131
  if downloaded is None:
132
- downloaded = latest_snapshot_from_manifest(token, repo_id, path_in_repo)
133
  if downloaded is None:
134
  return 0
135
  local_path.parent.mkdir(parents=True, exist_ok=True)
@@ -174,46 +237,93 @@ def upload_snapshot(token: str, repo_id: str, path_in_repo: str, local_path: Pat
174
 
175
  rotate_interval = env_int("KEEPER_HF_ROTATE_INTERVAL", DEFAULT_ROTATE_INTERVAL_SECONDS)
176
  rotate_keep = env_int("KEEPER_HF_ROTATE_KEEP", DEFAULT_ROTATE_KEEP)
 
 
 
 
 
177
  api = HfApi(token=token)
178
  temp_copy = backup_sqlite(local_path)
179
  try:
180
  checksum = sha256_file(temp_copy)
181
  metadata_path = manifest_path_for(path_in_repo) + ".state.json"
182
  current_state = load_json(download_repo_file(token, repo_id, metadata_path) or Path("/nonexistent"))
183
- if current_state and current_state.get("sha256") == checksum:
184
- return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
- operations = [
187
- CommitOperationAdd(path_in_repo=path_in_repo, path_or_fileobj=str(temp_copy)),
188
- CommitOperationAdd(
189
- path_in_repo=metadata_path,
190
- path_or_fileobj=json.dumps(
191
- {
192
- "updated_at": isoformat_utc(datetime.now(UTC)),
193
- "sha256": checksum,
194
- "size_bytes": temp_copy.stat().st_size,
195
- },
196
- ensure_ascii=False,
197
- indent=2,
198
- sort_keys=True,
199
- ).encode("utf-8"),
200
- ),
201
- ]
202
-
203
- if rotate_interval > 0 and rotate_keep > 0:
204
  bucket_time = floor_time(snapshot_time_for(temp_copy), rotate_interval)
205
  rotated_path = history_path_for(path_in_repo, bucket_time)
206
  manifest_path = manifest_path_for(path_in_repo)
207
  manifest = load_manifest(token, repo_id, manifest_path) or {}
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  previous_paths = [
209
  item
210
  for item in manifest.get("retained_paths", [])
211
  if isinstance(item, str) and item
212
  ]
213
- retained_paths = [rotated_path, *[item for item in previous_paths if item != rotated_path]][:rotate_keep]
214
- delete_paths = [item for item in previous_paths if item not in retained_paths]
215
- if manifest.get("latest_path") != rotated_path:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  operations.append(CommitOperationAdd(path_in_repo=rotated_path, path_or_fileobj=str(temp_copy)))
 
217
  operations.append(
218
  CommitOperationAdd(
219
  path_in_repo=manifest_path,
@@ -221,7 +331,7 @@ def upload_snapshot(token: str, repo_id: str, path_in_repo: str, local_path: Pat
221
  {
222
  "type": ROTATION_MANIFEST_TYPE,
223
  "version": 1,
224
- "latest_path": rotated_path,
225
  "updated_at": isoformat_utc(datetime.now(UTC)),
226
  "source_path": path_in_repo,
227
  "repo_type": repo_type(),
@@ -237,7 +347,10 @@ def upload_snapshot(token: str, repo_id: str, path_in_repo: str, local_path: Pat
237
  ).encode("utf-8"),
238
  )
239
  )
240
- operations.extend(CommitOperationDelete(path_in_repo=item) for item in delete_paths)
 
 
 
241
 
242
  api.create_commit(
243
  repo_id=repo_id,
 
18
 
19
 
20
  ROTATION_MANIFEST_TYPE = "daili_usage_keeper_sqlite_rotation_v1"
21
+ DEFAULT_ROTATE_INTERVAL_SECONDS = 60
22
  DEFAULT_ROTATE_KEEP = 48
23
 
24
 
 
43
  return default
44
 
45
 
46
+ def env_bool(name: str, default: bool = False) -> bool:
47
+ raw = env(name)
48
+ if not raw:
49
+ return default
50
+ return raw.lower() in {"1", "true", "yes", "on"}
51
+
52
+
53
  def isoformat_utc(value: datetime) -> str:
54
  return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
55
 
 
133
  return download_repo_file(token, repo_id, latest_path)
134
 
135
 
136
+ def is_history_path(path_in_repo: str, candidate: str) -> bool:
137
+ prefix = history_dir_for(path_in_repo).rstrip("/") + "/"
138
+ return candidate.startswith(prefix) and candidate != prefix
139
+
140
+
141
+ def list_repo_files_safe(api: HfApi, repo_id: str) -> set[str] | None:
142
+ try:
143
+ return set(api.list_repo_files(repo_id=repo_id, repo_type=repo_type()))
144
+ except Exception:
145
+ return None
146
+
147
+
148
+ def existing_history_paths(path_in_repo: str, repo_files: set[str] | None) -> list[str]:
149
+ if repo_files is None:
150
+ return []
151
+ return sorted(
152
+ (item for item in repo_files if is_history_path(path_in_repo, item)),
153
+ reverse=True,
154
+ )
155
+
156
+
157
+ def retained_history_paths(
158
+ path_in_repo: str,
159
+ current_path: str,
160
+ previous_paths: list[str],
161
+ existing_paths: list[str],
162
+ rotate_keep: int,
163
+ ) -> list[str]:
164
+ retained: list[str] = []
165
+ seen: set[str] = set()
166
+ for item in [current_path, *existing_paths, *previous_paths]:
167
+ if not item or item in seen or not is_history_path(path_in_repo, item):
168
+ continue
169
+ seen.add(item)
170
+ retained.append(item)
171
+ if len(retained) >= rotate_keep:
172
+ break
173
+ return retained
174
+
175
+
176
+ def deleted_history_paths(
177
+ path_in_repo: str,
178
+ previous_paths: list[str],
179
+ existing_paths: list[str],
180
+ retained_paths: list[str],
181
+ repo_listing_available: bool,
182
+ ) -> list[str]:
183
+ retained = set(retained_paths)
184
+ candidates = existing_paths if repo_listing_available else previous_paths
185
+ return [
186
+ item
187
+ for item in candidates
188
+ if is_history_path(path_in_repo, item) and item not in retained
189
+ ]
190
+
191
+
192
  def download_latest(token: str, repo_id: str, path_in_repo: str, local_path: Path) -> int:
193
+ downloaded = latest_snapshot_from_manifest(token, repo_id, path_in_repo)
194
  if downloaded is None:
195
+ downloaded = download_repo_file(token, repo_id, path_in_repo)
196
  if downloaded is None:
197
  return 0
198
  local_path.parent.mkdir(parents=True, exist_ok=True)
 
237
 
238
  rotate_interval = env_int("KEEPER_HF_ROTATE_INTERVAL", DEFAULT_ROTATE_INTERVAL_SECONDS)
239
  rotate_keep = env_int("KEEPER_HF_ROTATE_KEEP", DEFAULT_ROTATE_KEEP)
240
+ rotate_enabled = rotate_interval > 0 and rotate_keep > 0
241
+ write_latest = env_bool("KEEPER_HF_WRITE_LATEST", False)
242
+ if not rotate_enabled:
243
+ write_latest = True
244
+
245
  api = HfApi(token=token)
246
  temp_copy = backup_sqlite(local_path)
247
  try:
248
  checksum = sha256_file(temp_copy)
249
  metadata_path = manifest_path_for(path_in_repo) + ".state.json"
250
  current_state = load_json(download_repo_file(token, repo_id, metadata_path) or Path("/nonexistent"))
251
+ same_checksum = bool(current_state and current_state.get("sha256") == checksum)
252
+ repo_files = list_repo_files_safe(api, repo_id) if rotate_enabled else None
253
+
254
+ operations = []
255
+ if write_latest:
256
+ if not same_checksum:
257
+ operations.append(CommitOperationAdd(path_in_repo=path_in_repo, path_or_fileobj=str(temp_copy)))
258
+ elif repo_files is not None and path_in_repo in repo_files:
259
+ operations.append(CommitOperationDelete(path_in_repo=path_in_repo))
260
+
261
+ if not same_checksum:
262
+ operations.append(
263
+ CommitOperationAdd(
264
+ path_in_repo=metadata_path,
265
+ path_or_fileobj=json.dumps(
266
+ {
267
+ "updated_at": isoformat_utc(datetime.now(UTC)),
268
+ "sha256": checksum,
269
+ "size_bytes": temp_copy.stat().st_size,
270
+ },
271
+ ensure_ascii=False,
272
+ indent=2,
273
+ sort_keys=True,
274
+ ).encode("utf-8"),
275
+ )
276
+ )
277
 
278
+ if rotate_enabled:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  bucket_time = floor_time(snapshot_time_for(temp_copy), rotate_interval)
280
  rotated_path = history_path_for(path_in_repo, bucket_time)
281
  manifest_path = manifest_path_for(path_in_repo)
282
  manifest = load_manifest(token, repo_id, manifest_path) or {}
283
+ latest_path = manifest.get("latest_path")
284
+ latest_path_known = (
285
+ isinstance(latest_path, str)
286
+ and bool(latest_path)
287
+ and is_history_path(path_in_repo, latest_path)
288
+ )
289
+ latest_path_missing = bool(
290
+ repo_files is not None
291
+ and latest_path_known
292
+ and latest_path not in repo_files
293
+ )
294
+ needs_rotated_upload = (not same_checksum) or (not latest_path_known) or latest_path_missing
295
+ current_history_path = rotated_path if needs_rotated_upload else latest_path
296
  previous_paths = [
297
  item
298
  for item in manifest.get("retained_paths", [])
299
  if isinstance(item, str) and item
300
  ]
301
+ repo_listing_available = repo_files is not None
302
+ existing_paths = existing_history_paths(path_in_repo, repo_files)
303
+ retained_paths = retained_history_paths(
304
+ path_in_repo,
305
+ current_history_path,
306
+ previous_paths,
307
+ existing_paths,
308
+ rotate_keep,
309
+ )
310
+ delete_paths = deleted_history_paths(
311
+ path_in_repo,
312
+ previous_paths,
313
+ existing_paths,
314
+ retained_paths,
315
+ repo_listing_available,
316
+ )
317
+ needs_manifest_update = (
318
+ needs_rotated_upload
319
+ or manifest.get("retained_paths") != retained_paths
320
+ or manifest.get("rotation_interval_seconds") != rotate_interval
321
+ or manifest.get("retention_count") != rotate_keep
322
+ or bool(delete_paths)
323
+ )
324
+ if needs_rotated_upload:
325
  operations.append(CommitOperationAdd(path_in_repo=rotated_path, path_or_fileobj=str(temp_copy)))
326
+ if needs_manifest_update:
327
  operations.append(
328
  CommitOperationAdd(
329
  path_in_repo=manifest_path,
 
331
  {
332
  "type": ROTATION_MANIFEST_TYPE,
333
  "version": 1,
334
+ "latest_path": current_history_path,
335
  "updated_at": isoformat_utc(datetime.now(UTC)),
336
  "source_path": path_in_repo,
337
  "repo_type": repo_type(),
 
347
  ).encode("utf-8"),
348
  )
349
  )
350
+ operations.extend(CommitOperationDelete(path_in_repo=item) for item in delete_paths)
351
+
352
+ if not operations:
353
+ return 0
354
 
355
  api.create_commit(
356
  repo_id=repo_id,
hf_state_snapshot_test.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import patch
7
+
8
+ import hf_state_snapshot as snapshot
9
+
10
+
11
+ class RotationRetentionTest(unittest.TestCase):
12
+ def test_default_rotation_bucket_matches_upload_interval(self) -> None:
13
+ self.assertEqual(snapshot.DEFAULT_ROTATE_INTERVAL_SECONDS, 60)
14
+
15
+ def test_retention_keeps_current_and_newest_existing_paths(self) -> None:
16
+ path = "usage-keeper/app.db"
17
+ retained = snapshot.retained_history_paths(
18
+ path,
19
+ "usage-keeper/app.history/20260522T030000Z.db",
20
+ [
21
+ "usage-keeper/app.history/20260522T020000Z.db",
22
+ "usage-keeper/app.history/20260522T010000Z.db",
23
+ ],
24
+ [
25
+ "usage-keeper/app.history/20260522T020000Z.db",
26
+ "usage-keeper/app.history/20260522T010000Z.db",
27
+ "usage-keeper/app.history/20260522T000000Z.db",
28
+ ],
29
+ 3,
30
+ )
31
+
32
+ self.assertEqual(
33
+ retained,
34
+ [
35
+ "usage-keeper/app.history/20260522T030000Z.db",
36
+ "usage-keeper/app.history/20260522T020000Z.db",
37
+ "usage-keeper/app.history/20260522T010000Z.db",
38
+ ],
39
+ )
40
+
41
+ def test_cleanup_removes_orphaned_history_files_from_repo_listing(self) -> None:
42
+ path = "usage-keeper/app.db"
43
+ delete_paths = snapshot.deleted_history_paths(
44
+ path,
45
+ previous_paths=[
46
+ "usage-keeper/app.history/20260522T020000Z.db",
47
+ "usage-keeper/app.history/20260522T010000Z.db",
48
+ ],
49
+ existing_paths=[
50
+ "usage-keeper/app.history/20260522T020000Z.db",
51
+ "usage-keeper/app.history/20260522T010000Z.db",
52
+ "usage-keeper/app.history/20260522T000000Z.db",
53
+ "usage-keeper/other.history/20260522T000000Z.db",
54
+ ],
55
+ retained_paths=[
56
+ "usage-keeper/app.history/20260522T020000Z.db",
57
+ "usage-keeper/app.history/20260522T010000Z.db",
58
+ ],
59
+ repo_listing_available=True,
60
+ )
61
+
62
+ self.assertEqual(delete_paths, ["usage-keeper/app.history/20260522T000000Z.db"])
63
+
64
+ def test_upload_cleanup_does_not_overwrite_latest_path_when_rotating(self) -> None:
65
+ with tempfile.TemporaryDirectory() as tmpdir:
66
+ temp_copy = Path(tmpdir) / "app.db"
67
+ temp_copy.write_bytes(b"sqlite snapshot")
68
+ checksum = hashlib.sha256(temp_copy.read_bytes()).hexdigest()
69
+ state_file = Path(tmpdir) / "state.json"
70
+ state_file.write_text(json.dumps({"sha256": checksum}), encoding="utf-8")
71
+ local_db = Path(tmpdir) / "local.db"
72
+ local_db.write_bytes(b"source")
73
+
74
+ class FakeApi:
75
+ def __init__(self) -> None:
76
+ self.operations = []
77
+
78
+ def create_commit(self, **kwargs) -> None:
79
+ self.operations = kwargs["operations"]
80
+
81
+ fake_api = FakeApi()
82
+
83
+ def fake_download(_token: str, _repo_id: str, path_in_repo: str):
84
+ if path_in_repo.endswith(".state.json"):
85
+ return state_file
86
+ return None
87
+
88
+ with (
89
+ patch.object(snapshot, "HfApi", return_value=fake_api),
90
+ patch.object(snapshot, "backup_sqlite", return_value=temp_copy),
91
+ patch.object(snapshot, "download_repo_file", side_effect=fake_download),
92
+ patch.object(
93
+ snapshot,
94
+ "list_repo_files_safe",
95
+ return_value={
96
+ "usage-keeper/app.db",
97
+ "usage-keeper/app.history/20260522T030000Z.db",
98
+ "usage-keeper/app.history/20260522T020000Z.db",
99
+ },
100
+ ),
101
+ patch.object(
102
+ snapshot,
103
+ "load_manifest",
104
+ return_value={
105
+ "type": snapshot.ROTATION_MANIFEST_TYPE,
106
+ "latest_path": "usage-keeper/app.history/20260522T030000Z.db",
107
+ "rotation_interval_seconds": 3600,
108
+ "retention_count": 48,
109
+ "retained_paths": [
110
+ "usage-keeper/app.history/20260522T030000Z.db",
111
+ "usage-keeper/app.history/20260522T020000Z.db",
112
+ ],
113
+ },
114
+ ),
115
+ ):
116
+ self.assertEqual(snapshot.upload_snapshot("token", "repo", "usage-keeper/app.db", local_db), 0)
117
+
118
+ added_paths = {
119
+ operation.path_in_repo
120
+ for operation in fake_api.operations
121
+ if isinstance(operation, snapshot.CommitOperationAdd)
122
+ }
123
+ deleted_paths = {
124
+ operation.path_in_repo
125
+ for operation in fake_api.operations
126
+ if isinstance(operation, snapshot.CommitOperationDelete)
127
+ }
128
+
129
+ self.assertNotIn("usage-keeper/app.db", added_paths)
130
+ self.assertIn("usage-keeper/app.db", deleted_paths)
131
+ self.assertIn("usage-keeper/app.manifest.json", added_paths)
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()