STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
storage.py
Go to the documentation of this file.
1"""Version storage for designs: microversions + immutable releases.
2
3Payload-agnostic: it stores and returns whatever JSON dict it is given, which is
4why one module serves an engine design (``{"config": ...}``), a recovery config
5and a P&ID (``{"nodes": ..., "edges": ...}``) alike. The *working copy*
6(``current.json`` on the volume) is owned by the router
7(:mod:`stardesign.documents`); here we only keep version history.
8
9Two backends, chosen by :func:`make_backend` from the environment:
10
11* **S3Backend** when the app's bucket variable is set. Microversions ride on S3
12 object *versioning* of a per-document HEAD key
13 (``<prefix>/<user>/<id>/current.json``): every snapshot is a plain
14 ``put_object`` that S3 keeps as a new version, and a lifecycle rule prunes old
15 ones. Releases are separate immutable keys under ``.../releases/<label>.json``.
16 Credentials come from the standard ``AWS_*`` env vars.
17
18 This is also why designs are *not* stored in a flat, ownerless tree: the
19 microversion history of a design IS the S3 version history of one key, so
20 re-keying a design to a new owner-less path would flatten it.
21
22* **LocalBackend** otherwise (dev, and prod with no bucket set). Microversions
23 and releases are files on disk under the ``USERDATA_DIR`` tree, so history
24 works with no AWS.
25"""
26
27from __future__ import annotations
28
29import json
30import os
31import secrets
32import tempfile
33import time
34from datetime import datetime, timezone
35from pathlib import Path
36from typing import Any
37
38from stardesign.userdata import UserData, slugify
39
40Snapshot = dict[str, Any]
41
42
43def _now_iso() -> str:
44 return datetime.now(timezone.utc).isoformat()
45
46
47# ── Local (dev) backend ──────────────────────────────────────────────────────
48
49def _atomic_write(path: Path, data: Snapshot) -> None:
50 path.parent.mkdir(parents=True, exist_ok=True)
51 fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".json")
52 try:
53 with os.fdopen(fd, "w", encoding="utf-8") as fh:
54 json.dump(data, fh, indent=2)
55 fh.write("\n")
56 os.replace(tmp, path)
57 except BaseException:
58 if os.path.exists(tmp):
59 os.unlink(tmp)
60 raise
61
62
63def _read_json(path: Path) -> Snapshot | None:
64 if not path.is_file():
65 return None
66 try:
67 return json.loads(path.read_text("utf-8"))
68 except (OSError, ValueError):
69 return None
70
71
73 """Filesystem version history under ``<root>/<user>/<app>/<id>/``."""
74
75 def __init__(self, ud: UserData) -> None:
76 self._ud = ud
77
78 def _doc_dir(self, user: str, doc_id: str) -> Path:
79 return self._ud.user_dir(user) / doc_id
80
81 def _sub(self, user: str, doc_id: str, name: str) -> Path:
82 d = self._doc_dir(user, doc_id) / name
83 d.mkdir(parents=True, exist_ok=True)
84 return d
85
86 def snapshot_micro(self, user: str, doc_id: str, data: Snapshot) -> dict:
87 # Opaque, lowercase-hex id so it survives userdata.slugify on read-back,
88 # but time-ordered: a nanosecond timestamp prefix, then randomness to
89 # keep it unique. The prefix is what makes history order correct --
90 # filesystem mtime granularity is coarse enough that two snapshots
91 # written back-to-back can share an mtime, and with a purely random id
92 # the tie-break was arbitrary, so "restore the previous version" could
93 # hand back the wrong one. With delete gone this history is the only
94 # recovery path, so that order has to be right.
95 vid = f"{time.time_ns():016x}{secrets.token_hex(4)}"
96 _atomic_write(self._sub(user, doc_id, "versions") / f"{vid}.json", data)
97 return {"versionId": vid, "savedAt": _now_iso()}
98
99 def list_micro(self, user: str, doc_id: str, limit: int = 50) -> list[dict]:
100 """Snapshots newest first.
101
102 Sorted on ``st_mtime_ns`` then the id, not on the formatted ``savedAt``:
103 two snapshots written in the same filesystem timestamp tick otherwise
104 come back in whatever order ``glob`` happened to yield. The id carries a
105 nanosecond prefix (see :meth:`snapshot_micro`), so it breaks that tie in
106 real write order rather than arbitrarily. Ids written before that prefix
107 existed are fully random, but they are older files whose mtimes already
108 separate them from anything new.
109 """
110 rows: list[tuple[int, str, dict]] = []
111 for p in self._sub(user, doc_id, "versions").glob("*.json"):
112 if p.name.startswith("."):
113 continue
114 st = p.stat()
115 rows.append((st.st_mtime_ns, p.stem, {
116 "versionId": p.stem,
117 "savedAt": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(),
118 "size": st.st_size,
119 }))
120 rows.sort(key=lambda r: (r[0], r[1]), reverse=True)
121 return [r[2] for r in rows[:limit]]
122
123 def get_micro(self, user: str, doc_id: str, version_id: str) -> Snapshot | None:
124 safe = slugify(version_id)
125 if not safe:
126 return None
127 return _read_json(self._sub(user, doc_id, "versions") / f"{safe}.json")
128
129 def latest_micro(self, user: str, doc_id: str) -> Snapshot | None:
130 recent = self.list_micro(user, doc_id, limit=1)
131 return self.get_micro(user, doc_id, recent[0]["versionId"]) if recent else None
132
133 def create_release(self, user: str, doc_id: str, label: str, data: Snapshot) -> dict:
134 p = self._sub(user, doc_id, "releases") / f"{label}.json"
135 if p.exists():
136 raise FileExistsError(label)
137 _atomic_write(p, data)
138 return {"label": label, "savedAt": _now_iso()}
139
140 def list_releases(self, user: str, doc_id: str) -> list[dict]:
141 out: list[dict] = []
142 for p in self._sub(user, doc_id, "releases").glob("*.json"):
143 if p.name.startswith("."):
144 continue
145 st = p.stat()
146 out.append({
147 "label": p.stem,
148 "savedAt": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(),
149 "size": st.st_size,
150 })
151 out.sort(key=lambda r: r["savedAt"], reverse=True)
152 return out
153
154 def get_release(self, user: str, doc_id: str, label: str) -> Snapshot | None:
155 safe = slugify(label)
156 if not safe:
157 return None
158 return _read_json(self._sub(user, doc_id, "releases") / f"{safe}.json")
159
160
161# ── S3 backend ───────────────────────────────────────────────────────────────
162
164 """Version history in a versioned S3 bucket. Microversions == object versions."""
165
166 def __init__(self, ud: UserData, bucket: str, prefix: str) -> None:
167 self._ud = ud
168 import boto3 # imported lazily so dev never needs the dependency
169
170 self._s3 = boto3.client("s3")
171 self.bucket = bucket
172 self.prefix = prefix.strip("/")
173
174 def _head_key(self, user: str, doc_id: str) -> str:
175 return f"{self.prefix}/{user}/{doc_id}/current.json"
176
177 def _releases_prefix(self, user: str, doc_id: str) -> str:
178 return f"{self.prefix}/{user}/{doc_id}/releases/"
179
180 def _put(self, key: str, data: Snapshot) -> None:
181 self._s3.put_object(
182 Bucket=self.bucket, Key=key,
183 Body=json.dumps(data, indent=2).encode("utf-8"),
184 ContentType="application/json",
185 )
186
187 def _get(self, key: str, **kw) -> Snapshot | None:
188 from botocore.exceptions import ClientError
189 try:
190 obj = self._s3.get_object(Bucket=self.bucket, Key=key, **kw)
191 except ClientError:
192 return None
193 return json.loads(obj["Body"].read())
194
195 def snapshot_micro(self, user: str, doc_id: str, data: Snapshot) -> dict:
196 self._put(self._head_key(user, doc_id), data)
197 return {"savedAt": _now_iso()}
198
199 def list_micro(self, user: str, doc_id: str, limit: int = 50) -> list[dict]:
200 key = self._head_key(user, doc_id)
201 resp = self._s3.list_object_versions(Bucket=self.bucket, Prefix=key, MaxKeys=limit)
202 out = [
203 {"versionId": v["VersionId"],
204 "savedAt": v["LastModified"].isoformat(),
205 "size": v["Size"]}
206 for v in resp.get("Versions", []) if v["Key"] == key
207 ]
208 out.sort(key=lambda v: v["savedAt"], reverse=True)
209 return out[:limit]
210
211 def get_micro(self, user: str, doc_id: str, version_id: str) -> Snapshot | None:
212 return self._get(self._head_key(user, doc_id), VersionId=version_id)
213
214 def latest_micro(self, user: str, doc_id: str) -> Snapshot | None:
215 return self._get(self._head_key(user, doc_id))
216
217 def create_release(self, user: str, doc_id: str, label: str, data: Snapshot) -> dict:
218 from botocore.exceptions import ClientError
219 key = f"{self._releases_prefix(user, doc_id)}{label}.json"
220 try:
221 self._s3.head_object(Bucket=self.bucket, Key=key)
222 except ClientError as e:
223 if e.response.get("Error", {}).get("Code") not in ("404", "NoSuchKey", "NotFound"):
224 raise
225 else:
226 raise FileExistsError(label) # already exists -> immutable
227 self._put(key, data)
228 return {"label": label, "savedAt": _now_iso()}
229
230 def list_releases(self, user: str, doc_id: str) -> list[dict]:
231 prefix = self._releases_prefix(user, doc_id)
232 resp = self._s3.list_objects_v2(Bucket=self.bucket, Prefix=prefix)
233 out = []
234 for o in resp.get("Contents", []):
235 name = o["Key"][len(prefix):]
236 if name.endswith(".json"):
237 name = name[:-5]
238 out.append({"label": name, "savedAt": o["LastModified"].isoformat(), "size": o["Size"]})
239 out.sort(key=lambda r: r["savedAt"], reverse=True)
240 return out
241
242 def get_release(self, user: str, doc_id: str, label: str) -> Snapshot | None:
243 return self._get(f"{self._releases_prefix(user, doc_id)}{label}.json")
244
245
246# ── selection ────────────────────────────────────────────────────────────────
247
248def make_backend(ud: UserData, bucket_env: str, prefix_env: str, default_prefix: str):
249 """The version-history backend for one app, chosen from the environment.
250
251 Called once per app at import time, so a missing bucket means "local files"
252 for the life of the process rather than a per-request branch.
253 """
254 bucket = os.environ.get(bucket_env)
255 if bucket:
256 return S3Backend(ud, bucket, os.environ.get(prefix_env, default_prefix))
257 return LocalBackend(ud)
list[dict] list_releases(self, str user, str doc_id)
Definition storage.py:140
dict snapshot_micro(self, str user, str doc_id, Snapshot data)
Definition storage.py:86
dict create_release(self, str user, str doc_id, str label, Snapshot data)
Definition storage.py:133
Path _doc_dir(self, str user, str doc_id)
Definition storage.py:78
Snapshot|None get_release(self, str user, str doc_id, str label)
Definition storage.py:154
Snapshot|None latest_micro(self, str user, str doc_id)
Definition storage.py:129
Path _sub(self, str user, str doc_id, str name)
Definition storage.py:81
None __init__(self, UserData ud)
Definition storage.py:75
list[dict] list_micro(self, str user, str doc_id, int limit=50)
Definition storage.py:99
Snapshot|None get_micro(self, str user, str doc_id, str version_id)
Definition storage.py:123
Snapshot|None get_release(self, str user, str doc_id, str label)
Definition storage.py:242
Snapshot|None _get(self, str key, **kw)
Definition storage.py:187
str _head_key(self, str user, str doc_id)
Definition storage.py:174
list[dict] list_releases(self, str user, str doc_id)
Definition storage.py:230
None _put(self, str key, Snapshot data)
Definition storage.py:180
dict create_release(self, str user, str doc_id, str label, Snapshot data)
Definition storage.py:217
Snapshot|None get_micro(self, str user, str doc_id, str version_id)
Definition storage.py:211
list[dict] list_micro(self, str user, str doc_id, int limit=50)
Definition storage.py:199
str _releases_prefix(self, str user, str doc_id)
Definition storage.py:177
dict snapshot_micro(self, str user, str doc_id, Snapshot data)
Definition storage.py:195
Snapshot|None latest_micro(self, str user, str doc_id)
Definition storage.py:214
None __init__(self, UserData ud, str bucket, str prefix)
Definition storage.py:166
None _atomic_write(Path path, Snapshot data)
Definition storage.py:49
Snapshot|None _read_json(Path path)
Definition storage.py:63
make_backend(UserData ud, str bucket_env, str prefix_env, str default_prefix)
Definition storage.py:248
static double read(int32_t code)