django-sqlite-ops 0.1.0a1__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.
- django_sqlite_ops/__init__.py +1 -0
- django_sqlite_ops/apps.py +19 -0
- django_sqlite_ops/boot/__init__.py +4 -0
- django_sqlite_ops/boot/__main__.py +6 -0
- django_sqlite_ops/boot/cli.py +1066 -0
- django_sqlite_ops/boot/decide.py +350 -0
- django_sqlite_ops/boot/litestream.py +542 -0
- django_sqlite_ops/checks.py +400 -0
- django_sqlite_ops/compat/__init__.py +1 -0
- django_sqlite_ops/compat/_channels_lite_aio.py +49 -0
- django_sqlite_ops/compat/channels_lite.py +132 -0
- django_sqlite_ops/database.py +118 -0
- django_sqlite_ops/doctor.py +1051 -0
- django_sqlite_ops/health.py +1129 -0
- django_sqlite_ops/management/__init__.py +0 -0
- django_sqlite_ops/management/commands/__init__.py +0 -0
- django_sqlite_ops/management/commands/sqlite_doctor.py +161 -0
- django_sqlite_ops/restore_check.py +709 -0
- django_sqlite_ops/wal.py +287 -0
- django_sqlite_ops-0.1.0a1.dist-info/METADATA +987 -0
- django_sqlite_ops-0.1.0a1.dist-info/RECORD +23 -0
- django_sqlite_ops-0.1.0a1.dist-info/WHEEL +4 -0
- django_sqlite_ops-0.1.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""부팅 판정 순수 함수 (DESIGN §4-3, §4-4).
|
|
2
|
+
|
|
3
|
+
입력만 보고 상태와 조치를 정한다. 파일·subprocess·시간·환경변수를 건드리지 않는다.
|
|
4
|
+
Django 를 import 하지 않는다(DESIGN §4-1).
|
|
5
|
+
|
|
6
|
+
공개 입력은 정확한 타입(``type(x) is ...``)만 받는다. 판정이 로컬 DB 를 지킬지 덮을지를
|
|
7
|
+
정하므로 사용자 객체의 연산(``__eq__``·``__hash__``·``__bool__``·``__lt__``·``__repr__`` …)을
|
|
8
|
+
신뢰하지 않는다.
|
|
9
|
+
검증은 ``_normalize()`` 한 곳에서 하고, 그 뒤의 분류·조치는 정규화된 내장 값만 본다.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum, StrEnum, auto
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Action",
|
|
18
|
+
"Decision",
|
|
19
|
+
"OnUnknown",
|
|
20
|
+
"ReasonCode",
|
|
21
|
+
"Remote",
|
|
22
|
+
"RemoteEmpty",
|
|
23
|
+
"RemoteError",
|
|
24
|
+
"RemoteTxid",
|
|
25
|
+
"State",
|
|
26
|
+
"decide",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class State(StrEnum):
|
|
31
|
+
FRESH = "fresh"
|
|
32
|
+
MATCH = "match"
|
|
33
|
+
ADOPT = "adopt"
|
|
34
|
+
UNKNOWN = "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Action(StrEnum):
|
|
38
|
+
PROCEED = "proceed"
|
|
39
|
+
RESTORE = "restore"
|
|
40
|
+
QUARANTINE_AND_RESTORE = "quarantine_and_restore"
|
|
41
|
+
KEEP_LOCAL = "keep_local"
|
|
42
|
+
REFUSE = "refuse"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReasonCode(StrEnum):
|
|
46
|
+
# fresh
|
|
47
|
+
NEW_DB = "new_db"
|
|
48
|
+
RESTORE_FROM_REMOTE = "restore_from_remote"
|
|
49
|
+
# match
|
|
50
|
+
LOCAL_CURRENT = "local_current"
|
|
51
|
+
# adopt (D-11)
|
|
52
|
+
ADOPT_EXISTING = "adopt_existing"
|
|
53
|
+
# unknown
|
|
54
|
+
REMOTE_ERROR = "remote_error"
|
|
55
|
+
STALE_META = "stale_meta"
|
|
56
|
+
# DB 경로가 0바이트 정규 파일인데 로컬 메타 TXID 가 있음(#38, D-16).
|
|
57
|
+
# 메타가 DB 내용을 대변하지 않는다.
|
|
58
|
+
EMPTY_DB = "empty_db"
|
|
59
|
+
NO_LOCAL_META = "no_local_meta"
|
|
60
|
+
REMOTE_EMPTY = "remote_empty"
|
|
61
|
+
REMOTE_AHEAD = "remote_ahead"
|
|
62
|
+
# 로컬 DB·메타 없음 + 원격 빈 목록, init_new 없음(D-13). 복제본 경로 오타와 구분되지 않는다.
|
|
63
|
+
NO_REPLICA_NO_LOCAL = "no_replica_no_local"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OnUnknown(StrEnum):
|
|
67
|
+
REFUSE = "refuse"
|
|
68
|
+
RESTORE = "restore"
|
|
69
|
+
KEEP_LOCAL = "keep-local"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class RemoteError:
|
|
74
|
+
"""원격 조회 실패(타임아웃·인증·파싱 실패 모두)."""
|
|
75
|
+
|
|
76
|
+
message: str
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
_check_message(self.message)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class RemoteEmpty:
|
|
84
|
+
"""원격 조회 성공, 복제본 없음."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class RemoteTxid:
|
|
89
|
+
"""원격 조회 성공, 복제본의 최대 TXID."""
|
|
90
|
+
|
|
91
|
+
txid: int
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
_check_txid("remote txid", self.txid)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
Remote = RemoteError | RemoteEmpty | RemoteTxid
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class Decision:
|
|
102
|
+
state: State
|
|
103
|
+
action: Action
|
|
104
|
+
reason_code: ReasonCode
|
|
105
|
+
reason: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_SAFE_REPR = (type(None), bool, int, str)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _show(value: Any) -> str:
|
|
112
|
+
# 거부할 값의 __repr__ 도 사용자 코드다. 내장 값만 repr 하고 나머지는 타입 이름만 쓴다.
|
|
113
|
+
kind = type(value)
|
|
114
|
+
if any(kind is safe for safe in _SAFE_REPR):
|
|
115
|
+
return repr(value)
|
|
116
|
+
return f"<{kind.__qualname__} object>"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_txid(label: str, value: Any) -> int:
|
|
120
|
+
# 내장 int 만 받는다. 서브클래스는 비교 연산을 바꿔 검증과 판정을 우회할 수 있다.
|
|
121
|
+
if type(value) is not int or value < 0:
|
|
122
|
+
raise ValueError(f"invalid {label} {_show(value)}; must be a non-negative int")
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_message(value: Any) -> str:
|
|
127
|
+
if type(value) is not str:
|
|
128
|
+
raise ValueError(f"invalid remote error message {_show(value)}; must be a str")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_bool(label: str, value: Any) -> bool:
|
|
133
|
+
if type(value) is not bool:
|
|
134
|
+
raise ValueError(f"invalid {label} {_show(value)}; must be a bool")
|
|
135
|
+
return value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_POLICIES = {p.value: p for p in OnUnknown}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_policy(value: Any) -> OnUnknown:
|
|
142
|
+
# OnUnknown(value) 는 값의 __hash__·__eq__ 로 찾으므로 쓰지 않는다.
|
|
143
|
+
if type(value) is OnUnknown:
|
|
144
|
+
return value
|
|
145
|
+
if type(value) is str and value in _POLICIES:
|
|
146
|
+
return _POLICIES[value]
|
|
147
|
+
choices = ", ".join(_POLICIES)
|
|
148
|
+
raise ValueError(f"invalid on_unknown {_show(value)}; expected one of {choices}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _Kind(Enum):
|
|
152
|
+
ERROR = auto()
|
|
153
|
+
EMPTY = auto()
|
|
154
|
+
TXID = auto()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class _Remote:
|
|
159
|
+
"""API 경계에서 한 번 정규화한 원격 결과. 분류와 조치는 이 태그만 본다."""
|
|
160
|
+
|
|
161
|
+
kind: _Kind
|
|
162
|
+
txid: int | None = None
|
|
163
|
+
message: str = ""
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def text(self) -> str:
|
|
167
|
+
if self.kind is _Kind.TXID:
|
|
168
|
+
return f"txid {self.txid}"
|
|
169
|
+
if self.kind is _Kind.EMPTY:
|
|
170
|
+
return "empty"
|
|
171
|
+
return f"error: {self.message}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _normalize_remote(remote: Any) -> _Remote:
|
|
175
|
+
# isinstance 는 __class__ 를 속인 객체에 둘 이상 참이 될 수 있어 정확한 타입으로 가른다.
|
|
176
|
+
# 필드는 frozen 이라도 object.__setattr__ 로 바뀔 수 있으므로 생성자 검증과 별도로 다시 본다.
|
|
177
|
+
kind = type(remote)
|
|
178
|
+
if kind is RemoteError:
|
|
179
|
+
message = _check_message(remote.message)
|
|
180
|
+
# reason 은 한 줄이어야 한다. str.splitlines 의 줄 구분자를 모두 공백으로 바꾼다.
|
|
181
|
+
return _Remote(_Kind.ERROR, message=" ".join(message.splitlines()))
|
|
182
|
+
if kind is RemoteEmpty:
|
|
183
|
+
return _Remote(_Kind.EMPTY)
|
|
184
|
+
if kind is RemoteTxid:
|
|
185
|
+
return _Remote(_Kind.TXID, txid=_check_txid("remote txid", remote.txid))
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"invalid remote {_show(remote)}; expected exactly RemoteError, RemoteEmpty or RemoteTxid"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True, slots=True)
|
|
192
|
+
class _Inputs:
|
|
193
|
+
"""정규화된 입력. 모든 필드가 내장 값이거나 이 모듈의 타입이다."""
|
|
194
|
+
|
|
195
|
+
local_exists: bool
|
|
196
|
+
local_empty: bool
|
|
197
|
+
local_txid: int | None
|
|
198
|
+
remote: _Remote
|
|
199
|
+
policy: OnUnknown
|
|
200
|
+
adopt_existing: bool
|
|
201
|
+
init_new: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _normalize(
|
|
205
|
+
local_exists: Any,
|
|
206
|
+
local_empty: Any,
|
|
207
|
+
local_txid: Any,
|
|
208
|
+
remote: Any,
|
|
209
|
+
on_unknown: Any,
|
|
210
|
+
adopt_existing: Any,
|
|
211
|
+
init_new: Any,
|
|
212
|
+
) -> _Inputs:
|
|
213
|
+
"""공개 입력을 검증하는 유일한 지점. 정확한 타입이 아니면 dunder 를 부르기 전에 거부한다."""
|
|
214
|
+
exists = _check_bool("local_exists", local_exists)
|
|
215
|
+
empty = _check_bool("local_empty", local_empty)
|
|
216
|
+
if empty and not exists:
|
|
217
|
+
raise ValueError("invalid local_empty True with local_exists False; an empty db must exist")
|
|
218
|
+
return _Inputs(
|
|
219
|
+
local_exists=exists,
|
|
220
|
+
local_empty=empty,
|
|
221
|
+
local_txid=None if local_txid is None else _check_txid("local txid", local_txid),
|
|
222
|
+
remote=_normalize_remote(remote),
|
|
223
|
+
policy=_check_policy(on_unknown),
|
|
224
|
+
adopt_existing=_check_bool("adopt_existing", adopt_existing),
|
|
225
|
+
init_new=_check_bool("init_new", init_new),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _classify(inp: _Inputs) -> tuple[State, ReasonCode, str]:
|
|
230
|
+
local_exists, local_txid, remote = inp.local_exists, inp.local_txid, inp.remote
|
|
231
|
+
# 표의 순서대로 본다(DESIGN §4-3).
|
|
232
|
+
# DB 없이 메타만 남은 경우는 원격 결과와 무관하게 stale_meta 다.
|
|
233
|
+
if not local_exists and local_txid is not None:
|
|
234
|
+
return (
|
|
235
|
+
State.UNKNOWN,
|
|
236
|
+
ReasonCode.STALE_META,
|
|
237
|
+
f"no local db but local meta has txid {local_txid}; remote {remote.text}",
|
|
238
|
+
)
|
|
239
|
+
# 0바이트 DB 에 메타 TXID 가 남았으면 TXID 비교는 의미가 없다(#38, D-16).
|
|
240
|
+
# 원격 실패보다 먼저 본다(조치 표가 원격 실패면 어차피 거부한다, D-12).
|
|
241
|
+
# 메타 없는 0바이트는 아래 no_local_meta 로 간다. adopt 하지 않는다(#44, D-18).
|
|
242
|
+
if inp.local_empty and local_txid is not None:
|
|
243
|
+
return (
|
|
244
|
+
State.UNKNOWN,
|
|
245
|
+
ReasonCode.EMPTY_DB,
|
|
246
|
+
f"local db is 0 bytes but local meta has txid {local_txid}; remote {remote.text}",
|
|
247
|
+
)
|
|
248
|
+
if remote.kind is _Kind.ERROR:
|
|
249
|
+
where = "local db exists" if local_exists else "no local db"
|
|
250
|
+
return (
|
|
251
|
+
State.UNKNOWN,
|
|
252
|
+
ReasonCode.REMOTE_ERROR,
|
|
253
|
+
f"{where}, local txid {local_txid}; remote lookup failed: {remote.message}",
|
|
254
|
+
)
|
|
255
|
+
if not local_exists:
|
|
256
|
+
if remote.kind is _Kind.EMPTY:
|
|
257
|
+
# D-13: 빈 목록은 '복제본 없음'과 경로·prefix 오타를 구분하지 못한다(#3 실측).
|
|
258
|
+
if inp.init_new:
|
|
259
|
+
return State.FRESH, ReasonCode.NEW_DB, "no local db and remote empty; start new db"
|
|
260
|
+
return (
|
|
261
|
+
State.UNKNOWN,
|
|
262
|
+
ReasonCode.NO_REPLICA_NO_LOCAL,
|
|
263
|
+
"no local db and remote empty (replica path may be wrong); "
|
|
264
|
+
"use --init-new for the first deploy",
|
|
265
|
+
)
|
|
266
|
+
return (
|
|
267
|
+
State.FRESH,
|
|
268
|
+
ReasonCode.RESTORE_FROM_REMOTE,
|
|
269
|
+
f"no local db; restore remote txid {remote.txid}",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if local_txid is None:
|
|
273
|
+
# D-18: 0바이트 파일은 도입할 데이터가 없다. 도입하면 빈 DB 가 첫 세대가 된다.
|
|
274
|
+
if inp.adopt_existing and remote.kind is _Kind.EMPTY and not inp.local_empty:
|
|
275
|
+
return (
|
|
276
|
+
State.ADOPT,
|
|
277
|
+
ReasonCode.ADOPT_EXISTING,
|
|
278
|
+
"local db exists without meta and remote is empty; adopting existing db",
|
|
279
|
+
)
|
|
280
|
+
if inp.local_empty:
|
|
281
|
+
return (
|
|
282
|
+
State.UNKNOWN,
|
|
283
|
+
ReasonCode.NO_LOCAL_META,
|
|
284
|
+
f"local db is 0 bytes and local meta is missing; remote {remote.text}",
|
|
285
|
+
)
|
|
286
|
+
return (
|
|
287
|
+
State.UNKNOWN,
|
|
288
|
+
ReasonCode.NO_LOCAL_META,
|
|
289
|
+
f"local db exists but local meta is missing; remote {remote.text}",
|
|
290
|
+
)
|
|
291
|
+
if remote.kind is _Kind.EMPTY:
|
|
292
|
+
return (
|
|
293
|
+
State.UNKNOWN,
|
|
294
|
+
ReasonCode.REMOTE_EMPTY,
|
|
295
|
+
f"local db exists with txid {local_txid} but remote is empty",
|
|
296
|
+
)
|
|
297
|
+
assert remote.txid is not None
|
|
298
|
+
if local_txid >= remote.txid:
|
|
299
|
+
return (
|
|
300
|
+
State.MATCH,
|
|
301
|
+
ReasonCode.LOCAL_CURRENT,
|
|
302
|
+
f"local txid {local_txid} >= remote txid {remote.txid}",
|
|
303
|
+
)
|
|
304
|
+
return (
|
|
305
|
+
State.UNKNOWN,
|
|
306
|
+
ReasonCode.REMOTE_AHEAD,
|
|
307
|
+
f"local txid {local_txid} < remote txid {remote.txid}",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def decide(
|
|
312
|
+
*,
|
|
313
|
+
local_exists: bool,
|
|
314
|
+
local_txid: int | None,
|
|
315
|
+
remote: Remote,
|
|
316
|
+
local_empty: bool = False,
|
|
317
|
+
on_unknown: OnUnknown | str = OnUnknown.REFUSE,
|
|
318
|
+
adopt_existing: bool = False,
|
|
319
|
+
init_new: bool = False,
|
|
320
|
+
) -> Decision:
|
|
321
|
+
"""부팅 상태(fresh/match/adopt/unknown)와 조치를 정한다. 규칙표는 DESIGN §4-3.
|
|
322
|
+
|
|
323
|
+
``local_empty`` 는 DB 경로가 0바이트 정규 파일인지다. ``local_exists`` 가 거짓이면 참일 수 없다.
|
|
324
|
+
"""
|
|
325
|
+
inp = _normalize(
|
|
326
|
+
local_exists, local_empty, local_txid, remote, on_unknown, adopt_existing, init_new
|
|
327
|
+
)
|
|
328
|
+
state, code, reason = _classify(inp)
|
|
329
|
+
|
|
330
|
+
if state is State.FRESH:
|
|
331
|
+
action = Action.PROCEED if code is ReasonCode.NEW_DB else Action.RESTORE
|
|
332
|
+
elif state is State.MATCH or state is State.ADOPT:
|
|
333
|
+
action = Action.PROCEED
|
|
334
|
+
elif inp.remote.kind is _Kind.ERROR:
|
|
335
|
+
# D-12: 원격 조회 실패면 정책과 무관하게 거부한다(S3 장애 중 옛 볼륨 재부팅 방지).
|
|
336
|
+
action = Action.REFUSE
|
|
337
|
+
elif inp.local_empty and inp.policy is OnUnknown.KEEP_LOCAL:
|
|
338
|
+
# D-17·D-18: 0바이트 DB 는 메타 유무와 무관하게 keep-local 로도 넘기지 않는다.
|
|
339
|
+
# 지킬 로컬 데이터가 없다.
|
|
340
|
+
action = Action.REFUSE
|
|
341
|
+
elif inp.policy is OnUnknown.RESTORE:
|
|
342
|
+
# 복원할 복제본이 있을 때만 격리 후 복원한다. 빈 목록이면 거부한다.
|
|
343
|
+
action = Action.QUARANTINE_AND_RESTORE if inp.remote.kind is _Kind.TXID else Action.REFUSE
|
|
344
|
+
elif inp.policy is OnUnknown.KEEP_LOCAL:
|
|
345
|
+
# 로컬 DB 가 없으면 지킬 것이 없고, 새 DB 로 시작하면 원격을 덮을 수 있다.
|
|
346
|
+
action = Action.KEEP_LOCAL if inp.local_exists else Action.REFUSE
|
|
347
|
+
else:
|
|
348
|
+
action = Action.REFUSE
|
|
349
|
+
|
|
350
|
+
return Decision(state=state, action=action, reason_code=code, reason=reason)
|