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,1129 @@
|
|
|
1
|
+
"""복제 헬스 (DESIGN §7).
|
|
2
|
+
|
|
3
|
+
별칭마다 로컬 메타의 최대 TXID 와 복제본(``litestream ltx``)의 최대 TXID 를 직접 비교한다.
|
|
4
|
+
Litestream 은 S3 가 끊겨도 로그·``status``·메트릭에 아무것도 남기지 않기 때문이다(D3).
|
|
5
|
+
**DB 연결을 열지 않는다.** 로컬 TXID 는 메타 파일에서, 원격은 ``litestream ltx`` 로 읽는다.
|
|
6
|
+
|
|
7
|
+
요청마다 S3 를 부르지 않는다. 프로세스당 데몬 스레드 하나가 ``REFRESH`` 초마다 조회하고, 뷰는
|
|
8
|
+
마지막 결과를 읽기만 한다. 스레드는 첫 헬스 요청 때 시작한다(``ready()`` 에서 시작하지 않는다 —
|
|
9
|
+
관리 명령·테스트·마이그레이션에서 스레드가 돌지 않게). PID 가 바뀌면(포크) 다시 시작한다.
|
|
10
|
+
|
|
11
|
+
상태 계산은 시각과 조회 결과를 받는 순수 함수(``next_backlog_since``, ``alias_status``,
|
|
12
|
+
``overall_status``)에 모은다.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import stat
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable, Mapping
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import UTC, datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from django.conf import settings
|
|
31
|
+
from django.http import HttpRequest, JsonResponse
|
|
32
|
+
|
|
33
|
+
from .boot import litestream
|
|
34
|
+
from .boot.cli import real_path, state_path
|
|
35
|
+
from .boot.decide import Remote, RemoteEmpty, RemoteError, RemoteTxid
|
|
36
|
+
from .database import ENGINE
|
|
37
|
+
from .wal import IN_SYNC, NO_EVIDENCE, PENDING, WalEvidence, compare
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"BACKLOG",
|
|
41
|
+
"CAUGHT_UP",
|
|
42
|
+
"DEFAULT_BACKLOG_GRACE",
|
|
43
|
+
"DEFAULT_REFRESH",
|
|
44
|
+
"LOGGER_NAME",
|
|
45
|
+
"SCHEMA_VERSION",
|
|
46
|
+
"STALE_FACTOR",
|
|
47
|
+
"UNKNOWN",
|
|
48
|
+
"AliasConfig",
|
|
49
|
+
"FileTimes",
|
|
50
|
+
"HealthConfig",
|
|
51
|
+
"Monitor",
|
|
52
|
+
"Sample",
|
|
53
|
+
"Since",
|
|
54
|
+
"alias_status",
|
|
55
|
+
"file_times",
|
|
56
|
+
"files_went_backwards",
|
|
57
|
+
"get_monitor",
|
|
58
|
+
"health_view",
|
|
59
|
+
"next_backlog_since",
|
|
60
|
+
"next_pending_since",
|
|
61
|
+
"next_wal_pending_since",
|
|
62
|
+
"overall_status",
|
|
63
|
+
"parse_config",
|
|
64
|
+
"read_boot_state",
|
|
65
|
+
"redact",
|
|
66
|
+
"wal_evidence",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
SCHEMA_VERSION = 1
|
|
70
|
+
CAUGHT_UP = "caught_up"
|
|
71
|
+
BACKLOG = "backlog"
|
|
72
|
+
UNKNOWN = "unknown"
|
|
73
|
+
# 전체 상태는 가장 나쁜 별칭을 따른다. unknown 이 가장 나쁘다: 복제가 따라오는지 말할 수 없다는
|
|
74
|
+
# 뜻이라 backlog(뒤처졌지만 따라오는 중임을 안다)보다 더 많은 것을 감출 수 있기 때문이다.
|
|
75
|
+
_SEVERITY = {CAUGHT_UP: 0, BACKLOG: 1, UNKNOWN: 2}
|
|
76
|
+
|
|
77
|
+
DEFAULT_REFRESH = 15.0
|
|
78
|
+
DEFAULT_BACKLOG_GRACE = 60.0
|
|
79
|
+
# 마지막 결과가 REFRESH × 3 보다 오래됐으면 스레드가 멈춘 것으로 보고 unknown 이다.
|
|
80
|
+
STALE_FACTOR = 3
|
|
81
|
+
|
|
82
|
+
_HEALTH_KEYS = frozenset({"DATABASES", "REFRESH", "BACKLOG_GRACE"})
|
|
83
|
+
_ALIAS_KEYS = frozenset({"litestream_config", "meta_path", "litestream"})
|
|
84
|
+
# 부팅 상태 파일(DESIGN §7)에서 응답에 옮기는 키. 그 밖의 키는 옮기지 않는다.
|
|
85
|
+
_BOOT_KEYS = (
|
|
86
|
+
"state",
|
|
87
|
+
"action",
|
|
88
|
+
"reason_code",
|
|
89
|
+
"reason",
|
|
90
|
+
"unknown_at_boot",
|
|
91
|
+
"litestream_version",
|
|
92
|
+
"at",
|
|
93
|
+
)
|
|
94
|
+
_BOOT_MAX_BYTES = 64 * 1024
|
|
95
|
+
_MAX_REASON = 300
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _one_line(text: object) -> str:
|
|
99
|
+
line = " ".join(str(text).split())
|
|
100
|
+
return line if len(line) <= _MAX_REASON else line[: _MAX_REASON - 1] + "…"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# 원문 진단(Litestream stderr, 예외)은 응답에 넣지 않고 이 로거로만 남긴다.
|
|
104
|
+
LOGGER_NAME = "django_sqlite_ops.health"
|
|
105
|
+
_log = logging.getLogger(LOGGER_NAME)
|
|
106
|
+
|
|
107
|
+
# 가리는 규칙은 여기 한 곳이다(redact()).
|
|
108
|
+
# URL 의 userinfo: scheme://user:pass@host → scheme://***@host
|
|
109
|
+
_URL_USERINFO = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s@\"'`]+@")
|
|
110
|
+
_SECRET_WORDS = (
|
|
111
|
+
r"(?:secret|passw(?:or)?d|pwd|token|signature|sig|credential|access[-_]?key|api[-_]?key|auth)"
|
|
112
|
+
)
|
|
113
|
+
# URL 쿼리의 자격 증명류 값: ?X-Amz-Signature=... · &token=...
|
|
114
|
+
_QUERY_SECRET = re.compile(rf"(?i)([?&;][^=&\s]*{_SECRET_WORDS}[^=&\s]*=)[^&\s\"'`]*")
|
|
115
|
+
# key: value · key=value 꼴(YAML·환경 변수 메시지): secret-access-key: abc
|
|
116
|
+
_KV_SECRET = re.compile(
|
|
117
|
+
rf"(?i)\b([\w.-]*{_SECRET_WORDS}[\w.-]*)(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;&)\]}}]+)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def redact(text: str) -> str:
|
|
122
|
+
"""로그에 남기기 전에 URL userinfo·자격 증명류 쿼리 값·``key: value`` 비밀값을 가린다.
|
|
123
|
+
|
|
124
|
+
완전한 비밀 탐지기가 아니다. 그래서 원문은 응답에 넣지 않고(고정 사유만) 로그에만 남긴다.
|
|
125
|
+
"""
|
|
126
|
+
text = _URL_USERINFO.sub(r"\1***@", text)
|
|
127
|
+
text = _QUERY_SECRET.sub(r"\1***", text)
|
|
128
|
+
return _KV_SECRET.sub(r"\1\2***", text)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _iso(ts: float | None) -> str | None:
|
|
132
|
+
if ts is None:
|
|
133
|
+
return None
|
|
134
|
+
return datetime.fromtimestamp(ts, UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _hex(txid: int | None) -> str | None:
|
|
138
|
+
return None if txid is None else f"{txid:016x}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- 설정 ------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class AliasConfig:
|
|
146
|
+
alias: str
|
|
147
|
+
litestream_config: str
|
|
148
|
+
meta_path: str | None = None
|
|
149
|
+
litestream: str = "litestream"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class HealthConfig:
|
|
154
|
+
databases: tuple[AliasConfig, ...]
|
|
155
|
+
refresh: float = DEFAULT_REFRESH
|
|
156
|
+
backlog_grace: float = DEFAULT_BACKLOG_GRACE
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _path_text(value: Any) -> str | None:
|
|
160
|
+
if isinstance(value, os.PathLike):
|
|
161
|
+
value = os.fspath(value)
|
|
162
|
+
if not isinstance(value, str) or not value or "\0" in value:
|
|
163
|
+
return None
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _number(value: Any) -> float | None:
|
|
168
|
+
if type(value) not in (int, float) or not math.isfinite(value):
|
|
169
|
+
return None
|
|
170
|
+
return float(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _absolute(path: str) -> str:
|
|
174
|
+
"""작업 디렉터리를 붙이기만 한다. ``abspath()`` 처럼 ``..`` 를 접으면 D-15 검사를 우회한다."""
|
|
175
|
+
return path if os.path.isabs(path) else os.path.join(os.getcwd(), path)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _binary(binary: str) -> str:
|
|
179
|
+
"""경로 구분자가 든 바이너리는 절대 경로로, PATH 로 찾는 이름은 그대로 둔다."""
|
|
180
|
+
if os.sep in binary or (os.altsep and os.altsep in binary):
|
|
181
|
+
return os.path.abspath(binary)
|
|
182
|
+
return binary
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_config(
|
|
186
|
+
raw: Any, databases: Mapping[str, Any]
|
|
187
|
+
) -> tuple[HealthConfig | None, list[tuple[str, str | None]]]:
|
|
188
|
+
"""``SQLITE_OPS["HEALTH"]`` 를 검증한다. ``(설정 또는 None, [(메시지, 별칭|None)])``.
|
|
189
|
+
|
|
190
|
+
오류가 하나라도 있으면 설정은 ``None`` 이다. 시스템 체크 E002 가 같은 오류를 보고한다.
|
|
191
|
+
상대 경로는 이 프로세스의 작업 디렉터리를 앞에 붙인다. ``..``·링크는 접지 않는다(D-15 검사는
|
|
192
|
+
갱신 때 원문으로 한다).
|
|
193
|
+
"""
|
|
194
|
+
errors: list[tuple[str, str | None]] = []
|
|
195
|
+
if not isinstance(raw, Mapping):
|
|
196
|
+
return None, [(f"SQLITE_OPS['HEALTH'] must be a dict, got {type(raw).__name__}.", None)]
|
|
197
|
+
for key in sorted(set(map(str, raw)) - _HEALTH_KEYS):
|
|
198
|
+
errors.append((f"Unknown key {key!r} in SQLITE_OPS['HEALTH'].", None))
|
|
199
|
+
|
|
200
|
+
refresh = _number(raw.get("REFRESH", DEFAULT_REFRESH))
|
|
201
|
+
if refresh is None or refresh <= 0:
|
|
202
|
+
errors.append(
|
|
203
|
+
(
|
|
204
|
+
f"SQLITE_OPS['HEALTH']['REFRESH'] must be a positive number of seconds, "
|
|
205
|
+
f"got {raw.get('REFRESH')!r}.",
|
|
206
|
+
None,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
grace = _number(raw.get("BACKLOG_GRACE", DEFAULT_BACKLOG_GRACE))
|
|
210
|
+
if grace is None or grace < 0:
|
|
211
|
+
errors.append(
|
|
212
|
+
(
|
|
213
|
+
f"SQLITE_OPS['HEALTH']['BACKLOG_GRACE'] must be a non-negative number of seconds, "
|
|
214
|
+
f"got {raw.get('BACKLOG_GRACE')!r}.",
|
|
215
|
+
None,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
entries = raw.get("DATABASES")
|
|
220
|
+
aliases: list[AliasConfig] = []
|
|
221
|
+
if not isinstance(entries, Mapping) or not entries:
|
|
222
|
+
errors.append(("SQLITE_OPS['HEALTH']['DATABASES'] must be a non-empty dict.", None))
|
|
223
|
+
entries = {}
|
|
224
|
+
for alias, entry in entries.items():
|
|
225
|
+
where = f"SQLITE_OPS['HEALTH']['DATABASES'][{alias!r}]"
|
|
226
|
+
if not isinstance(alias, str):
|
|
227
|
+
errors.append((f"{where}: the alias must be a string.", None))
|
|
228
|
+
continue
|
|
229
|
+
db = databases.get(alias)
|
|
230
|
+
if not isinstance(db, Mapping):
|
|
231
|
+
errors.append((f"{where}: {alias!r} is not in DATABASES.", alias))
|
|
232
|
+
elif db.get("ENGINE") != ENGINE:
|
|
233
|
+
errors.append((f"{where}: {alias!r} is not a {ENGINE} database.", alias))
|
|
234
|
+
if not isinstance(entry, Mapping):
|
|
235
|
+
errors.append((f"{where} must be a dict, got {type(entry).__name__}.", alias))
|
|
236
|
+
continue
|
|
237
|
+
for key in sorted(set(map(str, entry)) - _ALIAS_KEYS):
|
|
238
|
+
errors.append((f"{where}: unknown key {key!r}.", alias))
|
|
239
|
+
config = _path_text(entry.get("litestream_config"))
|
|
240
|
+
if config is None:
|
|
241
|
+
errors.append((f"{where}['litestream_config'] must be a non-empty path.", alias))
|
|
242
|
+
meta = entry.get("meta_path")
|
|
243
|
+
if meta is not None and _path_text(meta) is None:
|
|
244
|
+
errors.append((f"{where}['meta_path'] must be a non-empty path.", alias))
|
|
245
|
+
binary = entry.get("litestream", "litestream")
|
|
246
|
+
if _path_text(binary) is None:
|
|
247
|
+
errors.append((f"{where}['litestream'] must be a non-empty string.", alias))
|
|
248
|
+
if errors:
|
|
249
|
+
continue
|
|
250
|
+
meta_text = _path_text(meta)
|
|
251
|
+
aliases.append(
|
|
252
|
+
AliasConfig(
|
|
253
|
+
alias,
|
|
254
|
+
_absolute(config),
|
|
255
|
+
_absolute(meta_text) if meta_text is not None else None,
|
|
256
|
+
_binary(_path_text(binary)),
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
if errors:
|
|
260
|
+
return None, errors
|
|
261
|
+
return HealthConfig(tuple(aliases), refresh, grace), []
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_config() -> tuple[HealthConfig | None, list[tuple[str, str | None]]]:
|
|
265
|
+
"""설정에서 읽는다. ``HEALTH`` 가 없으면 ``(None, [])``."""
|
|
266
|
+
ops = getattr(settings, "SQLITE_OPS", {})
|
|
267
|
+
if not isinstance(ops, Mapping):
|
|
268
|
+
return None, [("SQLITE_OPS must be a dict.", None)]
|
|
269
|
+
if "HEALTH" not in ops:
|
|
270
|
+
return None, []
|
|
271
|
+
return parse_config(ops["HEALTH"], settings.DATABASES)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# --- 조회 결과 -------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True, slots=True)
|
|
278
|
+
class Since:
|
|
279
|
+
"""어떤 상태를 처음 관측한 시점. ``mono`` 는 지속 시간 계산용, ``wall`` 은 표시용이다."""
|
|
280
|
+
|
|
281
|
+
mono: float
|
|
282
|
+
wall: float
|
|
283
|
+
key: Any = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass(frozen=True, slots=True)
|
|
287
|
+
class FileTimes:
|
|
288
|
+
"""DB 변경 시각과 최신 로컬 L0 의 시각(벽시계 mtime). ``ltx_key`` 는 (이름, mtime_ns).
|
|
289
|
+
|
|
290
|
+
보조 근거다(WAL 위치로 판정할 수 없을 때만 쓴다). ``db_mtime``·``wal_mtime`` 은 파일 시각
|
|
291
|
+
역행을 보려고 따로 둔다.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
db_changed_at: float | None
|
|
295
|
+
ltx_at: float | None
|
|
296
|
+
ltx_key: Any = None
|
|
297
|
+
db_mtime: float | None = None
|
|
298
|
+
wal_mtime: float | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, slots=True)
|
|
302
|
+
class Sample:
|
|
303
|
+
"""한 번의 갱신에서 본 별칭 하나의 사실. 판정은 ``alias_status()`` 가 한다.
|
|
304
|
+
|
|
305
|
+
``observed`` 는 monotonic 초(지속 시간 계산), ``checked_at`` 은 UNIX 초(표시용).
|
|
306
|
+
``error`` 는 조회 전에 판정이 끝난 사유(경로 규칙 위반, 파일 DB 가 아님, 갱신 중 예외)이고,
|
|
307
|
+
있으면 다른 필드와 무관하게 ``unknown`` 이다. 응답에 나가므로 고정 문장만 넣는다.
|
|
308
|
+
``db_file`` 은 DB 본 파일 상태의 사유 코드(``_DB_FILE_REASONS``)다. ``None`` 이면 내용 있는
|
|
309
|
+
정규 파일이거나(정상) 그 전에 판정이 끝나 보지 않았다.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
observed: float
|
|
313
|
+
checked_at: float
|
|
314
|
+
path: str | None = None
|
|
315
|
+
local: int | None = None
|
|
316
|
+
remote: Remote | None = None
|
|
317
|
+
boot_state: dict[str, Any] | None = None
|
|
318
|
+
boot_state_error: str | None = None
|
|
319
|
+
error_code: str | None = None
|
|
320
|
+
error: str | None = None
|
|
321
|
+
files: FileTimes | None = None
|
|
322
|
+
backlog_since: Since | None = None
|
|
323
|
+
pending_since: Since | None = None
|
|
324
|
+
wal: WalEvidence | None = None
|
|
325
|
+
backwards: bool = False
|
|
326
|
+
db_file: str | None = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def read_boot_state(db_path: str) -> tuple[dict[str, Any] | None, str | None]:
|
|
330
|
+
"""``<db>.boot-state.json`` 을 읽는다. ``(내용, 문제)`` 중 하나만 값이 있다.
|
|
331
|
+
|
|
332
|
+
파일이 없거나 형식이 틀리면 그 사실만 돌려준다(boot 를 쓰지 않는 배포도 있다).
|
|
333
|
+
"""
|
|
334
|
+
path = state_path(Path(os.path.abspath(db_path)))
|
|
335
|
+
try:
|
|
336
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
337
|
+
fd = os.open(path, flags)
|
|
338
|
+
except FileNotFoundError:
|
|
339
|
+
return None, "no boot state file (boot was not used, or has not run yet)"
|
|
340
|
+
except OSError as exc:
|
|
341
|
+
return None, _one_line(f"cannot open boot state file: {exc.strerror or exc}")
|
|
342
|
+
try:
|
|
343
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
344
|
+
return None, "boot state file is not a regular file"
|
|
345
|
+
data = os.read(fd, _BOOT_MAX_BYTES + 1)
|
|
346
|
+
except OSError as exc:
|
|
347
|
+
return None, _one_line(f"cannot read boot state file: {exc.strerror or exc}")
|
|
348
|
+
finally:
|
|
349
|
+
os.close(fd)
|
|
350
|
+
if len(data) > _BOOT_MAX_BYTES:
|
|
351
|
+
return None, "boot state file is too large"
|
|
352
|
+
try:
|
|
353
|
+
body = json.loads(data)
|
|
354
|
+
except ValueError:
|
|
355
|
+
return None, "boot state file is not valid JSON"
|
|
356
|
+
if type(body) is not dict or body.get("version") != 1:
|
|
357
|
+
return None, "boot state file has an unexpected format (version 1 expected)"
|
|
358
|
+
if type(body.get("unknown_at_boot")) is not bool:
|
|
359
|
+
return None, "boot state file has an unexpected format (unknown_at_boot)"
|
|
360
|
+
for key in _BOOT_KEYS:
|
|
361
|
+
value = body.get(key)
|
|
362
|
+
if value is not None and type(value) not in (str, bool):
|
|
363
|
+
return None, f"boot state file has an unexpected format ({key})"
|
|
364
|
+
picked = {key: body.get(key) for key in _BOOT_KEYS}
|
|
365
|
+
return {k: redact(v) if isinstance(v, str) else v for k, v in picked.items()}, None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# --- 판정 (순수 함수) ------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def next_backlog_since(
|
|
372
|
+
prev: Since | None, local: int | None, remote: Remote | None, observed: float, wall: float
|
|
373
|
+
) -> Since | None:
|
|
374
|
+
"""아직 올라가지 않은 로컬 TXID 를 처음 관측한 시점. 로컬이 앞서 있지 않으면 ``None``.
|
|
375
|
+
|
|
376
|
+
``key`` 는 그때의 로컬 TXID 다. 원격이 그 TXID 에 닿으면(올라감) 지금의 로컬 TXID 로 다시
|
|
377
|
+
센다. 그래서 지속 시간은 "관측한 미업로드 TXID 가 기다린 시간"이다: 쓰기가 계속되어 매 관측
|
|
378
|
+
순간 로컬이 한 걸음 앞서도 업로드가 따라오면 쌓이지 않고(실측: 30ms 간격 쓰기에서 0.5초마다
|
|
379
|
+
보면 늘 앞서 있다), 업로드가 쓰기를 못 따라가면 쌓인다. 프로세스 안에서만 추적하므로 재시작하면
|
|
380
|
+
초기화된다.
|
|
381
|
+
"""
|
|
382
|
+
if not (type(remote) is RemoteTxid and type(local) is int and local > remote.txid):
|
|
383
|
+
return None
|
|
384
|
+
if prev is not None and type(prev.key) is int and remote.txid < prev.key:
|
|
385
|
+
return prev
|
|
386
|
+
return Since(observed, wall, local)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def next_wal_pending_since(
|
|
390
|
+
prev: Since | None, wal: WalEvidence | None, observed: float, wall: float
|
|
391
|
+
) -> Since | None:
|
|
392
|
+
"""WAL 에 최신 L0 가 담지 않은 커밋이 있음을 처음 관측한 시점. 아니면 ``None``.
|
|
393
|
+
|
|
394
|
+
판정의 주 근거다(시계를 쓰지 않는다). 최신 L0 의 WAL 위치가 바뀌면(``wal.key``) 다시 센다:
|
|
395
|
+
Litestream 이 진행하는 한 바쁜 DB 에서도 지속 시간이 쌓이지 않는다.
|
|
396
|
+
"""
|
|
397
|
+
if wal is None or wal.state != PENDING:
|
|
398
|
+
return None
|
|
399
|
+
key = ("wal", wal.key)
|
|
400
|
+
if prev is not None and prev.key == key:
|
|
401
|
+
return prev
|
|
402
|
+
return Since(observed, wall, key)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def files_went_backwards(prev: FileTimes | None, cur: FileTimes | None) -> bool:
|
|
406
|
+
"""DB·WAL·최신 L0 의 mtime 중 하나라도 앞선 관측보다 이르면 참(벽시계 역행 등).
|
|
407
|
+
|
|
408
|
+
파일마다 앞뒤 관측 모두에 있을 때만 비교한다(``-wal`` 이 지워졌다 생기는 것은 역행이 아니다).
|
|
409
|
+
"""
|
|
410
|
+
if prev is None or cur is None:
|
|
411
|
+
return False
|
|
412
|
+
pairs = (
|
|
413
|
+
(prev.db_mtime, cur.db_mtime),
|
|
414
|
+
(prev.wal_mtime, cur.wal_mtime),
|
|
415
|
+
(prev.ltx_at, cur.ltx_at),
|
|
416
|
+
)
|
|
417
|
+
return any(a is not None and b is not None and b < a for a, b in pairs)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def next_pending_since(
|
|
421
|
+
prev: Since | None, files: FileTimes | None, observed: float, wall: float
|
|
422
|
+
) -> Since | None:
|
|
423
|
+
"""(보조) DB 파일이 최신 로컬 L0 보다 새로워진 것을 처음 관측한 시점. 아니면 ``None``.
|
|
424
|
+
|
|
425
|
+
WAL 위치로 판정할 수 없을 때만 쓴다. **파일 시각의 순서는 그 커밋이 L0 에 들어갔다는 증거가
|
|
426
|
+
아니다**(L0 는 복사를 시작할 때 WAL 범위를 정하고 끝날 때 mtime 이 찍힌다 — review-2 재현).
|
|
427
|
+
그래서 이 근거는 backlog 쪽으로만 쓰고, 아니라고 해서 ``in_sync`` 로 단정하지 않는다.
|
|
428
|
+
Litestream 은 DB 변경을 L0 로 기록하므로(``monitor-interval`` 기본 1초) 살아 있으면 곧 더 새
|
|
429
|
+
L0 가 생긴다. 최신 L0 가 바뀌면(이름·
|
|
430
|
+
mtime) 새로 센다: 쓰기가 계속되는 DB 에서 매 관측 순간 DB 가 조금 더 새로워도 Litestream 이
|
|
431
|
+
진행하는 한 지속 시간이 쌓이지 않는다. mtime 끼리의 차이는 지속 시간으로 쓰지 않는다(오래된
|
|
432
|
+
유휴 DB 에 첫 쓰기가 오면 차이가 바로 커 보인다).
|
|
433
|
+
"""
|
|
434
|
+
if (
|
|
435
|
+
files is None
|
|
436
|
+
or files.db_changed_at is None
|
|
437
|
+
or files.ltx_at is None
|
|
438
|
+
or files.db_changed_at <= files.ltx_at
|
|
439
|
+
):
|
|
440
|
+
return None
|
|
441
|
+
if prev is not None and prev.key == files.ltx_key:
|
|
442
|
+
return prev
|
|
443
|
+
return Since(observed, wall, files.ltx_key)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _wal_fields(wal: WalEvidence | None) -> dict[str, Any] | None:
|
|
447
|
+
if wal is None:
|
|
448
|
+
return None
|
|
449
|
+
return {
|
|
450
|
+
"evidence": wal.state,
|
|
451
|
+
"reason": wal.reason,
|
|
452
|
+
"ltx_wal_end": wal.ltx_end,
|
|
453
|
+
"wal_commit_end": wal.commit_end,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _fields(sample: Sample | None, now: float) -> dict[str, Any]:
|
|
458
|
+
if sample is None:
|
|
459
|
+
return {
|
|
460
|
+
"path": None,
|
|
461
|
+
"local_txid": None,
|
|
462
|
+
"remote_txid": None,
|
|
463
|
+
"checked_at": None,
|
|
464
|
+
"age": None,
|
|
465
|
+
"backlog_since": None,
|
|
466
|
+
"pending_since": None,
|
|
467
|
+
"db_changed_at": None,
|
|
468
|
+
"ltx_at": None,
|
|
469
|
+
"wal": None,
|
|
470
|
+
"boot_state": None,
|
|
471
|
+
"boot_state_error": None,
|
|
472
|
+
}
|
|
473
|
+
remote = sample.remote.txid if type(sample.remote) is RemoteTxid else None
|
|
474
|
+
files = sample.files
|
|
475
|
+
return {
|
|
476
|
+
"path": sample.path,
|
|
477
|
+
"local_txid": _hex(sample.local),
|
|
478
|
+
"remote_txid": _hex(remote),
|
|
479
|
+
"checked_at": _iso(sample.checked_at),
|
|
480
|
+
"age": round(now - sample.observed, 3),
|
|
481
|
+
"backlog_since": _iso(sample.backlog_since.wall) if sample.backlog_since else None,
|
|
482
|
+
"pending_since": _iso(sample.pending_since.wall) if sample.pending_since else None,
|
|
483
|
+
"db_changed_at": _iso(files.db_changed_at) if files else None,
|
|
484
|
+
"ltx_at": _iso(files.ltx_at) if files else None,
|
|
485
|
+
"wal": _wal_fields(sample.wal),
|
|
486
|
+
"boot_state": sample.boot_state,
|
|
487
|
+
"boot_state_error": sample.boot_state_error,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _stale_cause(
|
|
492
|
+
alias: str | None,
|
|
493
|
+
checking: tuple[str, float] | None,
|
|
494
|
+
slowest: tuple[str, float] | None,
|
|
495
|
+
now: float,
|
|
496
|
+
refresh: float,
|
|
497
|
+
) -> str:
|
|
498
|
+
"""``stale`` 사유의 뒷부분. 갱신 스레드가 지금 조회 중인 별칭과 그 경과 초를 밝힌다(#72).
|
|
499
|
+
|
|
500
|
+
조회 중이 아니면(스레드가 ``REFRESH`` 를 쉬는 중이거나 멈춤) 직전 라운드에서 가장 오래 걸린
|
|
501
|
+
조회가 ``REFRESH`` 이상일 때만 그 별칭과 초를 밝힌다. 그보다 짧은 조회 하나로는 나이가
|
|
502
|
+
``REFRESH × 3`` 을 넘은 까닭이 되지 못하므로 기존 문장을 쓴다.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
def name(other: str) -> str:
|
|
506
|
+
return "this alias" if other == alias else f"alias {other!r}"
|
|
507
|
+
|
|
508
|
+
if checking is not None:
|
|
509
|
+
busy, started = checking
|
|
510
|
+
return f"the refresh thread has been checking {name(busy)} for {max(now - started, 0):.0f}s"
|
|
511
|
+
if slowest is not None and slowest[1] >= refresh:
|
|
512
|
+
return f"the last refresh spent {slowest[1]:.0f}s on {name(slowest[0])}"
|
|
513
|
+
return "the refresh thread may be stuck"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _verdict(
|
|
517
|
+
sample: Sample | None,
|
|
518
|
+
now: float,
|
|
519
|
+
refresh: float,
|
|
520
|
+
grace: float,
|
|
521
|
+
alias: str | None = None,
|
|
522
|
+
checking: tuple[str, float] | None = None,
|
|
523
|
+
slowest: tuple[str, float] | None = None,
|
|
524
|
+
) -> tuple[str, str, str]:
|
|
525
|
+
"""``(상태, 사유 코드, 사유)``. 사유는 고정 문장에 경로·TXID·초·별칭 이름만 넣는다(비밀값 없음).
|
|
526
|
+
|
|
527
|
+
``checking`` 은 갱신 스레드가 지금 조회 중인 ``(별칭, 시작 monotonic 초)``, ``slowest`` 는 직전
|
|
528
|
+
갱신 라운드에서 가장 오래 걸린 조회의 ``(별칭, 걸린 초)`` 다(없으면 ``None``).
|
|
529
|
+
"""
|
|
530
|
+
if sample is None:
|
|
531
|
+
return UNKNOWN, "not_checked", "not checked yet; the first refresh has not finished"
|
|
532
|
+
age = now - sample.observed
|
|
533
|
+
if age < 0 or age > refresh * STALE_FACTOR:
|
|
534
|
+
return (
|
|
535
|
+
UNKNOWN,
|
|
536
|
+
"stale",
|
|
537
|
+
(
|
|
538
|
+
f"the last check is {age:.0f}s old (more than {STALE_FACTOR} x REFRESH); "
|
|
539
|
+
+ _stale_cause(alias, checking, slowest, now, refresh)
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
if sample.error is not None:
|
|
543
|
+
return UNKNOWN, sample.error_code or "error", sample.error
|
|
544
|
+
if sample.db_file is not None:
|
|
545
|
+
# WAL·TXID 가 맞아도 그것이 붙을 DB 본 파일이 없거나 비면 정상이라 할 수 없다(#40).
|
|
546
|
+
template = _DB_FILE_REASONS.get(sample.db_file, _DB_FILE_REASONS["db_not_regular"])
|
|
547
|
+
return UNKNOWN, sample.db_file, template.format(path=sample.path)
|
|
548
|
+
if sample.boot_state is not None and sample.boot_state.get("unknown_at_boot") is True:
|
|
549
|
+
return (
|
|
550
|
+
UNKNOWN,
|
|
551
|
+
"unknown_at_boot",
|
|
552
|
+
(
|
|
553
|
+
"unknown_at_boot: boot proceeded with --on-unknown keep-local; replication state "
|
|
554
|
+
"is unknown until the next boot that is not keep-local"
|
|
555
|
+
),
|
|
556
|
+
)
|
|
557
|
+
remote = sample.remote
|
|
558
|
+
if type(remote) is RemoteError:
|
|
559
|
+
return (
|
|
560
|
+
UNKNOWN,
|
|
561
|
+
"remote_error",
|
|
562
|
+
(f"remote lookup failed (litestream ltx); details are in the {LOGGER_NAME} log"),
|
|
563
|
+
)
|
|
564
|
+
if type(remote) is RemoteEmpty:
|
|
565
|
+
return (
|
|
566
|
+
UNKNOWN,
|
|
567
|
+
"remote_empty",
|
|
568
|
+
(
|
|
569
|
+
"the replica is empty (or the replica path/prefix is wrong; litestream reports "
|
|
570
|
+
"both the same way)"
|
|
571
|
+
),
|
|
572
|
+
)
|
|
573
|
+
if type(remote) is not RemoteTxid:
|
|
574
|
+
return UNKNOWN, "no_remote", "no remote result"
|
|
575
|
+
if sample.local is None:
|
|
576
|
+
return (
|
|
577
|
+
UNKNOWN,
|
|
578
|
+
"no_local_meta",
|
|
579
|
+
("no readable local Litestream metadata (is litestream replicate running?)"),
|
|
580
|
+
)
|
|
581
|
+
if remote.txid > sample.local:
|
|
582
|
+
return (
|
|
583
|
+
UNKNOWN,
|
|
584
|
+
"remote_ahead",
|
|
585
|
+
(
|
|
586
|
+
f"the replica is ahead of local ({remote.txid:016x} > {sample.local:016x}); "
|
|
587
|
+
"another machine may be writing to the same replica"
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
if remote.txid < sample.local:
|
|
591
|
+
since = sample.backlog_since.mono if sample.backlog_since else sample.observed
|
|
592
|
+
behind = sample.observed - since
|
|
593
|
+
if behind >= grace:
|
|
594
|
+
return (
|
|
595
|
+
BACKLOG,
|
|
596
|
+
"local_ahead",
|
|
597
|
+
(
|
|
598
|
+
f"local has been ahead of the replica for {behind:.0f}s "
|
|
599
|
+
f"(BACKLOG_GRACE {grace:g}s)"
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
wal = sample.wal
|
|
603
|
+
pending = sample.observed - sample.pending_since.mono if sample.pending_since else None
|
|
604
|
+
if wal is None or wal.state not in (PENDING, IN_SYNC):
|
|
605
|
+
# WAL 로는 판정할 수 없다. 파일 시각은 보조 근거라 backlog 쪽으로만 쓴다.
|
|
606
|
+
why = wal.reason if wal is not None else "no WAL evidence"
|
|
607
|
+
files = sample.files
|
|
608
|
+
if sample.backwards:
|
|
609
|
+
return (
|
|
610
|
+
UNKNOWN,
|
|
611
|
+
"file_time_backwards",
|
|
612
|
+
(
|
|
613
|
+
"a DB, -wal or L0 file time moved backwards since the last check (clock "
|
|
614
|
+
f"change?) and the -wal gives no evidence ({why})"
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
if pending is not None and pending >= grace:
|
|
618
|
+
return (
|
|
619
|
+
BACKLOG,
|
|
620
|
+
"db_not_replicated",
|
|
621
|
+
(
|
|
622
|
+
f"DB files changed at {_iso(files.db_changed_at if files else None)} after "
|
|
623
|
+
f"the latest local L0 ({_iso(files.ltx_at if files else None)})"
|
|
624
|
+
f" and stayed so for {pending:.0f}s (BACKLOG_GRACE {grace:g}s); is the "
|
|
625
|
+
"replicate process running? (file times only; the -wal gives no evidence: "
|
|
626
|
+
f"{why})"
|
|
627
|
+
),
|
|
628
|
+
)
|
|
629
|
+
return (
|
|
630
|
+
UNKNOWN,
|
|
631
|
+
"no_wal_evidence",
|
|
632
|
+
f"cannot tell whether every commit is in the latest local L0: {why}",
|
|
633
|
+
)
|
|
634
|
+
if wal.state == PENDING and pending is not None and pending >= grace:
|
|
635
|
+
return (
|
|
636
|
+
BACKLOG,
|
|
637
|
+
"db_not_replicated",
|
|
638
|
+
(
|
|
639
|
+
f"the -wal has commits after the latest local L0 (L0 ends at WAL byte "
|
|
640
|
+
f"{wal.ltx_end}) for {pending:.0f}s (BACKLOG_GRACE {grace:g}s); is the "
|
|
641
|
+
"replicate process running?"
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
if remote.txid < sample.local:
|
|
645
|
+
behind = sample.observed - (
|
|
646
|
+
sample.backlog_since.mono if sample.backlog_since else sample.observed
|
|
647
|
+
)
|
|
648
|
+
return (
|
|
649
|
+
CAUGHT_UP,
|
|
650
|
+
"local_ahead_within_grace",
|
|
651
|
+
(f"local is ahead of the replica for {behind:.0f}s, within BACKLOG_GRACE {grace:g}s"),
|
|
652
|
+
)
|
|
653
|
+
if wal.state == PENDING:
|
|
654
|
+
return (
|
|
655
|
+
CAUGHT_UP,
|
|
656
|
+
"db_changed_within_grace",
|
|
657
|
+
(
|
|
658
|
+
f"the -wal has commits after the latest local L0 for {pending or 0:.0f}s, "
|
|
659
|
+
f"within BACKLOG_GRACE {grace:g}s"
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
return (
|
|
663
|
+
CAUGHT_UP,
|
|
664
|
+
"in_sync",
|
|
665
|
+
"local and replica are at the same TXID and every -wal commit is in the latest L0",
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def alias_status(
|
|
670
|
+
sample: Sample | None,
|
|
671
|
+
now: float,
|
|
672
|
+
*,
|
|
673
|
+
refresh: float,
|
|
674
|
+
grace: float,
|
|
675
|
+
alias: str | None = None,
|
|
676
|
+
checking: tuple[str, float] | None = None,
|
|
677
|
+
slowest: tuple[str, float] | None = None,
|
|
678
|
+
) -> dict[str, Any]:
|
|
679
|
+
"""별칭 하나의 상태(응답의 ``databases[alias]``). ``now`` 는 monotonic 초다.
|
|
680
|
+
|
|
681
|
+
판정 순서(처음 맞는 것): 조회 전 · 오래된 결과(``REFRESH × 3`` 초과) · 사전 오류 ·
|
|
682
|
+
DB 본 파일 없음·0바이트·정규 파일 아님·조회 실패 · ``unknown_at_boot`` · 원격 실패 ·
|
|
683
|
+
원격 빈 목록 · 로컬 메타 없음 · 원격이 앞섬 → ``unknown``.
|
|
684
|
+
로컬 TXID 가 앞선 지속 시간 ≥ ``grace`` → ``backlog``. 그다음 WAL 위치(주 근거)로 판단할 수
|
|
685
|
+
없으면: 파일 시각 역행 → ``unknown``, 파일 시각(보조)이 grace 이상 L0 보다 새로움 →
|
|
686
|
+
``backlog``, 그 밖 → ``unknown``(``caught_up`` 으로 단정하지 않는다). WAL 에 L0 뒤 커밋이
|
|
687
|
+
grace 이상 남음 → ``backlog``. 그 밖은 ``caught_up``. 지속 시간은 monotonic 관측
|
|
688
|
+
시각(``observed``)끼리의 차이다. ``stale`` 사유에는 갱신 스레드가 지금 조회 중인 별칭
|
|
689
|
+
(``checking``)과 그 경과 초, 조회 중이 아니면 직전 라운드의 가장 느린 조회(``slowest``)를
|
|
690
|
+
덧붙인다(#72).
|
|
691
|
+
"""
|
|
692
|
+
status, code, reason = _verdict(sample, now, refresh, grace, alias, checking, slowest)
|
|
693
|
+
return {"status": status, "code": code, "reason": _one_line(reason), **_fields(sample, now)}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def overall_status(statuses: Mapping[str, Mapping[str, Any]] | list[str]) -> str:
|
|
697
|
+
"""가장 나쁜 상태(``unknown`` > ``backlog`` > ``caught_up``). 별칭이 없으면 ``unknown``."""
|
|
698
|
+
values = [s["status"] for s in statuses.values()] if isinstance(statuses, Mapping) else statuses
|
|
699
|
+
if not values:
|
|
700
|
+
return UNKNOWN
|
|
701
|
+
return max(values, key=lambda s: _SEVERITY.get(s, _SEVERITY[UNKNOWN]))
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# --- 조회 ------------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _db_path(alias: str) -> tuple[str | None, str | None]:
|
|
708
|
+
"""Django 가 여는 파일 경로(연결하지 않는다). ``(경로, 문제)``."""
|
|
709
|
+
from . import doctor # doctor 는 checks 를 import 한다. 순환을 피해 늦게 읽는다.
|
|
710
|
+
|
|
711
|
+
config = settings.DATABASES.get(alias)
|
|
712
|
+
if not isinstance(config, Mapping):
|
|
713
|
+
return None, f"{alias!r} is not in DATABASES"
|
|
714
|
+
target = doctor._target(alias, config)
|
|
715
|
+
if target.role != "write":
|
|
716
|
+
return None, f"not a writable file database (role {target.role})"
|
|
717
|
+
if target.path is None:
|
|
718
|
+
return None, "the database path is empty"
|
|
719
|
+
return target.path, None
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _check_real(path: str, what: str) -> tuple[str | None, str | None]:
|
|
723
|
+
"""D-15: ``(실제 경로, 문제)``. 원문의 ``..``·부모 링크를 접지 않고 boot 의 규칙으로 본다."""
|
|
724
|
+
try:
|
|
725
|
+
real = real_path(path)
|
|
726
|
+
except ValueError as exc:
|
|
727
|
+
_log.debug("%s %s rejected: %s", what, path, redact(str(exc)))
|
|
728
|
+
return None, (
|
|
729
|
+
f"{what} {path} is not a real path (symlink or '..' in it, or the parent is "
|
|
730
|
+
"missing); use the real path (D-15)"
|
|
731
|
+
)
|
|
732
|
+
if os.path.islink(real):
|
|
733
|
+
return None, f"{what} {real} is a symbolic link; use the real path (D-15)"
|
|
734
|
+
return str(real), None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
_DB_FILE_REASONS = {
|
|
738
|
+
"db_missing": (
|
|
739
|
+
"the database file {path} does not exist; check the boot log and --on-unknown restore "
|
|
740
|
+
"(it can be missing briefly while boot quarantines or installs a restore)"
|
|
741
|
+
),
|
|
742
|
+
"db_empty": (
|
|
743
|
+
"the database file {path} is empty (0 bytes); check the boot log (empty_db) and "
|
|
744
|
+
"--on-unknown restore (it can be empty briefly while boot installs a restore)"
|
|
745
|
+
),
|
|
746
|
+
"db_not_regular": "the database path {path} is not a regular file",
|
|
747
|
+
"db_stat_failed": (
|
|
748
|
+
f"cannot stat the database file {{path}}; details are in the {LOGGER_NAME} log"
|
|
749
|
+
),
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _db_file(path: str) -> tuple[str | None, str | None]:
|
|
754
|
+
"""DB 본 파일 상태(#40). ``(사유 코드, 원문 진단)``. 내용 있는 정규 파일이면 코드가 ``None``.
|
|
755
|
+
|
|
756
|
+
``lstat`` 한 번만 한다. 열지 않으므로 DB·``-wal``·``-shm`` 을 만들거나 바꾸지 않는다. 마지막
|
|
757
|
+
요소의 링크는 ``_check_real`` 이 이미 거부했다. 원문 진단은 로그에만 쓴다.
|
|
758
|
+
"""
|
|
759
|
+
try:
|
|
760
|
+
st = os.lstat(path)
|
|
761
|
+
except FileNotFoundError:
|
|
762
|
+
return "db_missing", None
|
|
763
|
+
except (OSError, ValueError) as exc:
|
|
764
|
+
return "db_stat_failed", f"cannot stat the database file {path}: {exc}"
|
|
765
|
+
if not stat.S_ISREG(st.st_mode):
|
|
766
|
+
return "db_not_regular", None
|
|
767
|
+
return ("db_empty" if st.st_size == 0 else None), None
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _mtime(path: str) -> float | None:
|
|
771
|
+
try:
|
|
772
|
+
return os.stat(path).st_mtime
|
|
773
|
+
except (OSError, ValueError):
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def file_times(db: str, meta: str | None) -> FileTimes:
|
|
778
|
+
"""DB 변경 시각 ``max(mtime(DB), mtime(DB-wal))``(있는 것만)과 최신 로컬 L0 파일의 시각.
|
|
779
|
+
|
|
780
|
+
``-shm`` 은 읽기에도 바뀌므로 보지 않는다. ``-wal`` 만 볼 수는 없다: 마지막 연결이 닫히면
|
|
781
|
+
SQLite 가 체크포인트한 뒤 ``-wal`` 을 지운다(빌드에 따라 남긴다). L0 후보는
|
|
782
|
+
``local_max_txid()`` 와 같은 것이다.
|
|
783
|
+
|
|
784
|
+
0.5.17 실측(DESIGN §7): Litestream 이 도는 동안에는 앱의 읽기·쓰기·Litestream 의 체크포인트
|
|
785
|
+
뒤 곧(``monitor-interval`` 1초) 새 L0 가 생겨 DB 가 L0 보다 새로운 상태가 이어지지 않는다.
|
|
786
|
+
멈춰 있으면 앱의 마지막 연결이 닫힐 때의 체크포인트(읽기만 했어도)가 DB mtime 을 바꾸고,
|
|
787
|
+
``replicate -once`` 가 끝날 때도 바뀐다(SIGTERM 종료는 바꾸지 않았다). 그래서 이 근거가
|
|
788
|
+
grace 를 넘기면 "쓰기가 복제되지 않았다"가 아니라 "DB 가 쓰이는데 Litestream 이 진행하지
|
|
789
|
+
않는다"로 읽는다.
|
|
790
|
+
"""
|
|
791
|
+
db_mtime, wal_mtime = _mtime(db), _mtime(db + "-wal")
|
|
792
|
+
times = [t for t in (db_mtime, wal_mtime) if t is not None]
|
|
793
|
+
found = litestream.local_max_ltx(db, meta_path=meta)
|
|
794
|
+
ltx_at = ltx_key = None
|
|
795
|
+
if found is not None:
|
|
796
|
+
try:
|
|
797
|
+
st = os.stat(found[1])
|
|
798
|
+
ltx_at, ltx_key = st.st_mtime, (found[1].name, st.st_mtime_ns)
|
|
799
|
+
except OSError:
|
|
800
|
+
pass
|
|
801
|
+
return FileTimes(max(times) if times else None, ltx_at, ltx_key, db_mtime, wal_mtime)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def wal_evidence(db: str, meta: str | None) -> WalEvidence:
|
|
805
|
+
"""최신 로컬 L0 의 WAL 위치와 현재 ``-wal`` 의 커밋 위치를 비교한다(``wal.compare``).
|
|
806
|
+
|
|
807
|
+
``key`` 는 (L0 이름, L0 의 WAL 끝, salt) — L0 가 바뀌면 pending 을 다시 센다.
|
|
808
|
+
"""
|
|
809
|
+
found = litestream.local_max_ltx(db, meta_path=meta)
|
|
810
|
+
if found is None:
|
|
811
|
+
return WalEvidence(NO_EVIDENCE, "no readable local L0")
|
|
812
|
+
rng = litestream.ltx_wal_range(found[1])
|
|
813
|
+
evidence = compare(db + "-wal", rng)
|
|
814
|
+
key = None if rng is None else (found[1].name, rng.end, rng.salt1, rng.salt2)
|
|
815
|
+
return dataclasses.replace(evidence, key=key)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
Probe = Callable[[AliasConfig, str], Remote]
|
|
819
|
+
LocalProbe = Callable[[str, str | None], int | None]
|
|
820
|
+
FileProbe = Callable[[str, str | None], FileTimes | None]
|
|
821
|
+
WalProbe = Callable[[str, str | None], WalEvidence | None]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _remote(cfg: AliasConfig, db: str) -> Remote:
|
|
825
|
+
return litestream.remote_max_txid(db, config=cfg.litestream_config, binary=cfg.litestream)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _local(db: str, meta: str | None) -> int | None:
|
|
829
|
+
return litestream.local_max_txid(db, meta_path=meta)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@dataclass
|
|
833
|
+
class Monitor:
|
|
834
|
+
"""프로세스당 하나. 데몬 스레드가 ``refresh`` 초마다 ``samples`` 를 갱신한다.
|
|
835
|
+
|
|
836
|
+
``clock`` 은 표시용 벽시계, ``monotonic`` 은 지속 시간(grace·stale·pending)용이다.
|
|
837
|
+
"""
|
|
838
|
+
|
|
839
|
+
config: HealthConfig
|
|
840
|
+
remote: Probe = _remote
|
|
841
|
+
local: LocalProbe = _local
|
|
842
|
+
files: FileProbe = file_times
|
|
843
|
+
wal: WalProbe = wal_evidence
|
|
844
|
+
path_of: Callable[[str], tuple[str | None, str | None]] = _db_path
|
|
845
|
+
clock: Callable[[], float] = time.time
|
|
846
|
+
monotonic: Callable[[], float] = time.monotonic
|
|
847
|
+
samples: dict[str, Sample] = field(default_factory=dict, init=False)
|
|
848
|
+
refreshes: int = field(default=0, init=False)
|
|
849
|
+
_logged: dict[tuple[str, str], str] = field(default_factory=dict, init=False)
|
|
850
|
+
_pid: int | None = field(default=None, init=False)
|
|
851
|
+
_thread: threading.Thread | None = field(default=None, init=False)
|
|
852
|
+
_stop: threading.Event = field(default_factory=threading.Event, init=False)
|
|
853
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
|
|
854
|
+
# 갱신 스레드가 지금 조회 중인 (별칭, 시작 monotonic 초). stale 사유에 쓴다(#72).
|
|
855
|
+
_checking: tuple[str, float] | None = field(default=None, init=False)
|
|
856
|
+
# 직전 갱신 라운드에서 가장 오래 걸린 조회 (별칭, 걸린 초). 쉬는 동안의 stale 사유에 쓴다(#72).
|
|
857
|
+
_slowest: tuple[str, float] | None = field(default=None, init=False)
|
|
858
|
+
|
|
859
|
+
# -- 갱신 --
|
|
860
|
+
|
|
861
|
+
def _now(self) -> tuple[float, float]:
|
|
862
|
+
return self.monotonic(), self.clock()
|
|
863
|
+
|
|
864
|
+
def _log_once(self, alias: str, message: str, key: str = "remote") -> None:
|
|
865
|
+
"""원문 진단은 로그로만(가린 뒤). 같은 메시지를 갱신마다 반복하지 않는다."""
|
|
866
|
+
message = redact(message)
|
|
867
|
+
if self._logged.get((alias, key)) != message:
|
|
868
|
+
self._logged[(alias, key)] = message
|
|
869
|
+
_log.warning("health %r: %s", alias, message)
|
|
870
|
+
|
|
871
|
+
def _sample(self, cfg: AliasConfig, prev: Sample | None) -> Sample:
|
|
872
|
+
observed, wall = self._now()
|
|
873
|
+
path, problem = self.path_of(cfg.alias)
|
|
874
|
+
if problem is not None or path is None:
|
|
875
|
+
return Sample(
|
|
876
|
+
observed, wall, error_code="not_file_db", error=problem or "no database path"
|
|
877
|
+
)
|
|
878
|
+
real, problem = _check_real(path, "database path")
|
|
879
|
+
meta = None
|
|
880
|
+
if problem is None and cfg.meta_path is not None:
|
|
881
|
+
meta, problem = _check_real(cfg.meta_path, "meta_path")
|
|
882
|
+
boot_state, boot_error = read_boot_state(real or path)
|
|
883
|
+
if problem is not None or real is None:
|
|
884
|
+
return Sample(
|
|
885
|
+
observed,
|
|
886
|
+
wall,
|
|
887
|
+
path=path,
|
|
888
|
+
boot_state=boot_state,
|
|
889
|
+
boot_state_error=boot_error,
|
|
890
|
+
error_code="path_not_real",
|
|
891
|
+
error=problem,
|
|
892
|
+
)
|
|
893
|
+
# DB 본 파일이 없거나 비어도 아래 조회와 타이머 갱신은 그대로 한다: boot 격리·설치 사이처럼
|
|
894
|
+
# 잠깐일 때 끊으면 이후 backlog 감지가 늦어진다. 판정만 unknown 이다(_verdict).
|
|
895
|
+
db_file, db_detail = _db_file(real)
|
|
896
|
+
if db_detail is not None:
|
|
897
|
+
self._log_once(cfg.alias, db_detail, "db_file")
|
|
898
|
+
else:
|
|
899
|
+
self._logged.pop((cfg.alias, "db_file"), None)
|
|
900
|
+
remote = self.remote(cfg, real)
|
|
901
|
+
if type(remote) is RemoteError:
|
|
902
|
+
self._log_once(cfg.alias, f"remote lookup failed: {remote.message}")
|
|
903
|
+
else:
|
|
904
|
+
self._logged.pop((cfg.alias, "remote"), None)
|
|
905
|
+
local = self.local(real, meta)
|
|
906
|
+
wal = self.wal(real, meta)
|
|
907
|
+
files = self.files(real, meta)
|
|
908
|
+
observed, wall = self._now()
|
|
909
|
+
backlog = next_backlog_since(
|
|
910
|
+
prev.backlog_since if prev else None, local, remote, observed, wall
|
|
911
|
+
)
|
|
912
|
+
prev_pending = prev.pending_since if prev else None
|
|
913
|
+
backwards = files_went_backwards(prev.files if prev else None, files)
|
|
914
|
+
if wal is not None and wal.state in (PENDING, IN_SYNC):
|
|
915
|
+
pending = next_wal_pending_since(prev_pending, wal, observed, wall)
|
|
916
|
+
elif backwards:
|
|
917
|
+
pending = prev_pending # 역행했다고 앞선 미복제 근거를 지우지 않는다
|
|
918
|
+
else:
|
|
919
|
+
pending = next_pending_since(prev_pending, files, observed, wall)
|
|
920
|
+
return Sample(
|
|
921
|
+
observed,
|
|
922
|
+
wall,
|
|
923
|
+
real,
|
|
924
|
+
local,
|
|
925
|
+
remote,
|
|
926
|
+
boot_state,
|
|
927
|
+
boot_error,
|
|
928
|
+
files=files,
|
|
929
|
+
backlog_since=backlog,
|
|
930
|
+
pending_since=pending,
|
|
931
|
+
wal=wal,
|
|
932
|
+
backwards=backwards,
|
|
933
|
+
db_file=db_file,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
def refresh_once(self) -> None:
|
|
937
|
+
"""모든 별칭을 한 번 갱신한다. 예외는 삼키고 그 별칭을 ``unknown`` 사유로 남긴다."""
|
|
938
|
+
slowest: tuple[str, float] | None = None
|
|
939
|
+
for cfg in self.config.databases:
|
|
940
|
+
try:
|
|
941
|
+
started = self.monotonic()
|
|
942
|
+
except Exception: # noqa: BLE001
|
|
943
|
+
started = time.monotonic()
|
|
944
|
+
with self._lock:
|
|
945
|
+
prev = self.samples.get(cfg.alias)
|
|
946
|
+
self._checking = (cfg.alias, started)
|
|
947
|
+
try:
|
|
948
|
+
try:
|
|
949
|
+
sample = self._sample(cfg, prev)
|
|
950
|
+
except Exception as exc: # noqa: BLE001 - 스레드가 죽지 않게 사유로 남긴다
|
|
951
|
+
try:
|
|
952
|
+
self._log_once(cfg.alias, f"refresh failed: {type(exc).__name__}: {exc}")
|
|
953
|
+
except Exception: # noqa: BLE001
|
|
954
|
+
pass
|
|
955
|
+
try:
|
|
956
|
+
observed, wall = self._now()
|
|
957
|
+
except Exception: # noqa: BLE001
|
|
958
|
+
observed, wall = time.monotonic(), time.time()
|
|
959
|
+
sample = Sample(
|
|
960
|
+
observed,
|
|
961
|
+
wall,
|
|
962
|
+
error_code="refresh_failed",
|
|
963
|
+
error=f"refresh failed ({type(exc).__name__}); details are in the "
|
|
964
|
+
f"{LOGGER_NAME} log",
|
|
965
|
+
)
|
|
966
|
+
with self._lock:
|
|
967
|
+
self.samples[cfg.alias] = sample
|
|
968
|
+
finally:
|
|
969
|
+
# Exception 밖의 예외로 스레드가 죽어도 '조회 중' 표시를 남기지 않는다
|
|
970
|
+
with self._lock:
|
|
971
|
+
self._checking = None
|
|
972
|
+
spent = sample.observed - started # observed 는 조회가 끝난 뒤 찍힌다
|
|
973
|
+
if slowest is None or spent > slowest[1]:
|
|
974
|
+
slowest = (cfg.alias, spent)
|
|
975
|
+
with self._lock:
|
|
976
|
+
self.refreshes += 1
|
|
977
|
+
self._slowest = slowest
|
|
978
|
+
|
|
979
|
+
def _run(self, stop: threading.Event) -> None:
|
|
980
|
+
while not stop.is_set():
|
|
981
|
+
try:
|
|
982
|
+
self.refresh_once()
|
|
983
|
+
except Exception: # noqa: BLE001 - refresh_once 는 던지지 않지만 스레드를 지킨다
|
|
984
|
+
pass
|
|
985
|
+
stop.wait(self.config.refresh)
|
|
986
|
+
|
|
987
|
+
# -- 스레드 --
|
|
988
|
+
|
|
989
|
+
@property
|
|
990
|
+
def running(self) -> bool:
|
|
991
|
+
return self._thread is not None and self._pid == os.getpid() and self._thread.is_alive()
|
|
992
|
+
|
|
993
|
+
def ensure_started(self) -> None:
|
|
994
|
+
"""스레드가 없거나, 다른 프로세스(포크 전 부모)의 것이면 새로 시작한다."""
|
|
995
|
+
pid = os.getpid()
|
|
996
|
+
if self._pid != pid:
|
|
997
|
+
# 포크된 자식: 부모의 잠금·결과·스레드 객체를 물려받았다. 처음부터 다시 한다.
|
|
998
|
+
self._lock = threading.Lock()
|
|
999
|
+
self._stop = threading.Event()
|
|
1000
|
+
self.samples = {}
|
|
1001
|
+
self._logged = {}
|
|
1002
|
+
self._checking = None
|
|
1003
|
+
self._slowest = None
|
|
1004
|
+
self._thread = None
|
|
1005
|
+
self._pid = pid
|
|
1006
|
+
if self._thread is not None and self._thread.is_alive():
|
|
1007
|
+
return
|
|
1008
|
+
self._stop = threading.Event()
|
|
1009
|
+
self._thread = threading.Thread(
|
|
1010
|
+
target=self._run, args=(self._stop,), name="sqlite-ops-health", daemon=True
|
|
1011
|
+
)
|
|
1012
|
+
self._thread.start()
|
|
1013
|
+
|
|
1014
|
+
def stop(self, timeout: float | None = None) -> None:
|
|
1015
|
+
self._stop.set()
|
|
1016
|
+
if self._thread is not None and self._pid == os.getpid():
|
|
1017
|
+
self._thread.join(timeout)
|
|
1018
|
+
|
|
1019
|
+
# -- 보고 --
|
|
1020
|
+
|
|
1021
|
+
def report(self, now: float | None = None) -> dict[str, Any]:
|
|
1022
|
+
"""``now`` 는 monotonic 초(없으면 지금)."""
|
|
1023
|
+
now = self.monotonic() if now is None else now
|
|
1024
|
+
with self._lock:
|
|
1025
|
+
samples = dict(self.samples)
|
|
1026
|
+
checking = self._checking
|
|
1027
|
+
slowest = self._slowest
|
|
1028
|
+
databases = {
|
|
1029
|
+
cfg.alias: alias_status(
|
|
1030
|
+
samples.get(cfg.alias),
|
|
1031
|
+
now,
|
|
1032
|
+
refresh=self.config.refresh,
|
|
1033
|
+
grace=self.config.backlog_grace,
|
|
1034
|
+
alias=cfg.alias,
|
|
1035
|
+
checking=checking,
|
|
1036
|
+
slowest=slowest,
|
|
1037
|
+
)
|
|
1038
|
+
for cfg in self.config.databases
|
|
1039
|
+
}
|
|
1040
|
+
return {
|
|
1041
|
+
"version": SCHEMA_VERSION,
|
|
1042
|
+
"status": overall_status(databases),
|
|
1043
|
+
"refresh": self.config.refresh,
|
|
1044
|
+
"backlog_grace": self.config.backlog_grace,
|
|
1045
|
+
"databases": databases,
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
_monitor: Monitor | None = None
|
|
1050
|
+
_monitor_lock = threading.Lock()
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _reset_lock_after_fork() -> None:
|
|
1054
|
+
# 포크 순간 다른 스레드가 잡고 있던 잠금을 자식이 영원히 기다리지 않게 한다.
|
|
1055
|
+
global _monitor_lock
|
|
1056
|
+
_monitor_lock = threading.Lock()
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
if hasattr(os, "register_at_fork"):
|
|
1060
|
+
os.register_at_fork(after_in_child=_reset_lock_after_fork)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def get_monitor() -> tuple[Monitor | None, str | None, str | None]:
|
|
1064
|
+
"""설정으로 만든 프로세스의 ``Monitor`` 를 돌려주고 스레드를 시작한다.
|
|
1065
|
+
|
|
1066
|
+
``(모니터, 사유 코드, 사유)``. 설정 오류의 원문(설정값이 들어 있다)은 응답에 넣지 않는다.
|
|
1067
|
+
"""
|
|
1068
|
+
global _monitor
|
|
1069
|
+
with _monitor_lock:
|
|
1070
|
+
if _monitor is None:
|
|
1071
|
+
config, errors = load_config()
|
|
1072
|
+
if config is None:
|
|
1073
|
+
if errors:
|
|
1074
|
+
return (
|
|
1075
|
+
None,
|
|
1076
|
+
"invalid_config",
|
|
1077
|
+
("invalid SQLITE_OPS['HEALTH']; run manage.py check (sqlite_ops.E002)"),
|
|
1078
|
+
)
|
|
1079
|
+
return None, "not_configured", "SQLITE_OPS['HEALTH'] is not configured"
|
|
1080
|
+
_monitor = Monitor(config)
|
|
1081
|
+
_monitor.ensure_started()
|
|
1082
|
+
return _monitor, None, None
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# --- 뷰 --------------------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
_STRICT_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
class _AllAliases(set):
|
|
1091
|
+
"""모든 별칭을 포함하는 ``_non_atomic_requests``.
|
|
1092
|
+
|
|
1093
|
+
Django 5.2·6.1 의 ``BaseHandler.make_view_atomic()`` 은 ``ATOMIC_REQUESTS`` 가 켜진 별칭마다
|
|
1094
|
+
``alias not in view._non_atomic_requests`` 일 때만 뷰를 ``atomic(using=alias)`` 로 감싼다
|
|
1095
|
+
(5.2.18·6.1.2 ``django/core/handlers/base.py``). ``transaction.non_atomic_requests(using)`` 은
|
|
1096
|
+
별칭 하나만 넣으므로, 설정에 어떤 별칭이 있든 모두 빠지게 이 집합을 쓴다. 감싸면 뷰 실행 전에
|
|
1097
|
+
연결이 열리고 DB 파일이 생긴다(review-1 재현).
|
|
1098
|
+
"""
|
|
1099
|
+
|
|
1100
|
+
def __contains__(self, alias: object) -> bool:
|
|
1101
|
+
return True
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def health_view(request: HttpRequest) -> JsonResponse:
|
|
1105
|
+
"""복제 헬스 JSON. HTTP 상태는 항상 200 이다(``?strict=1`` 이면 ``caught_up`` 이 아닐 때 503).
|
|
1106
|
+
|
|
1107
|
+
복제가 뒤처졌다고 로드밸런서가 앱을 빼면 서비스까지 멈춘다. 모니터링은 본문의 ``status`` 로
|
|
1108
|
+
알람을 건다. 인증하지 않으므로 내부망에만 노출한다. 요청 트랜잭션(``ATOMIC_REQUESTS``)에서
|
|
1109
|
+
모든 별칭이 빠진다.
|
|
1110
|
+
"""
|
|
1111
|
+
monitor, code, problem = get_monitor()
|
|
1112
|
+
if monitor is None:
|
|
1113
|
+
body: dict[str, Any] = {
|
|
1114
|
+
"version": SCHEMA_VERSION,
|
|
1115
|
+
"status": UNKNOWN,
|
|
1116
|
+
"code": code,
|
|
1117
|
+
"reason": problem,
|
|
1118
|
+
"databases": {},
|
|
1119
|
+
}
|
|
1120
|
+
else:
|
|
1121
|
+
body = monitor.report()
|
|
1122
|
+
strict = request.GET.get("strict", "").lower() in _STRICT_TRUE
|
|
1123
|
+
status = 503 if strict and body["status"] != CAUGHT_UP else 200
|
|
1124
|
+
response = JsonResponse(body, status=status)
|
|
1125
|
+
response["Cache-Control"] = "no-store"
|
|
1126
|
+
return response
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
health_view._non_atomic_requests = _AllAliases() # type: ignore[attr-defined]
|