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.
@@ -0,0 +1,287 @@
1
+ """SQLite WAL 의 커밋 위치를 파일 읽기만으로 계산해 최신 L0 가 담은 위치와 비교한다 (DESIGN §7).
2
+
3
+ DB 연결을 열지 않는다. WAL 파일을 ``O_RDONLY`` 로 열어 헤더와 프레임 헤더만 읽는다.
4
+ 표준 라이브러리만 쓴다.
5
+
6
+ 형식(https://www.sqlite.org/fileformat2.html#walformat):
7
+ - WAL 헤더 32바이트(big-endian): ``[0:4]`` 매직(0x377f0682 → 체크섬 little-endian,
8
+ 0x377f0683 → big-endian), ``[4:8]`` 버전 3007000, ``[8:12]`` page size, ``[12:16]``
9
+ checkpoint seq, ``[16:20]`` salt-1, ``[20:24]`` salt-2, ``[24:32]`` 앞 24바이트의 체크섬.
10
+ - 프레임 헤더 24바이트 + 페이지: ``[0:4]`` page no, ``[4:8]`` 커밋 프레임이면 커밋 뒤 DB 크기
11
+ (페이지 수, 0 이 아님), ``[8:12]``·``[12:16]`` salt, ``[16:24]`` 누적 체크섬(이전 체크섬에서
12
+ 이어 프레임 헤더 앞 8바이트와 페이지 데이터로 계산).
13
+ - 프레임은 salt 가 헤더와 같고 누적 체크섬이 맞을 때만 유효하다. 첫 무효 프레임에서 WAL 이
14
+ 끝난 것으로 본다(SQLite 의 복구 규칙, Litestream 0.5.17 ``WALReader.readFrame`` 과 같음).
15
+
16
+ 체크섬까지 검증한다. salt 만 보면 쓰는 중인(헤더는 쓰였고 페이지는 덜 쓰인) 프레임을 커밋으로
17
+ 셀 수 있고, Litestream 이 보는 유효 범위와 어긋난다. 비용은 L0 끝 **뒤의** 프레임만, 첫 커밋
18
+ 프레임까지만 읽어 줄인다(L0 끝 바로 앞 프레임의 체크섬 필드에서 이어 계산한다 — Litestream
19
+ ``NewWALReaderWithOffset`` 과 같은 방식).
20
+ """
21
+
22
+ import os
23
+ import stat
24
+ import struct
25
+ from dataclasses import dataclass
26
+
27
+ from .boot.litestream import LtxWalRange
28
+
29
+ __all__ = [
30
+ "IN_SYNC",
31
+ "NO_EVIDENCE",
32
+ "PENDING",
33
+ "SCAN_LIMIT",
34
+ "WalEvidence",
35
+ "WalHeader",
36
+ "compare",
37
+ "read_wal_header",
38
+ ]
39
+
40
+ WAL_HEADER_SIZE = 32
41
+ FRAME_HEADER_SIZE = 24
42
+ _MAGIC_LE = 0x377F0682
43
+ _MAGIC_BE = 0x377F0683
44
+ _VERSION = 3007000
45
+ _PAGE_SIZES = frozenset(1 << n for n in range(9, 17))
46
+ # 한 번에 읽는 최대 바이트. 이 안에서 커밋 프레임을 못 찾으면(거대한 트랜잭션이 쓰이는 중)
47
+ # 판정하지 않는다.
48
+ SCAN_LIMIT = 64 * 1024 * 1024
49
+
50
+ PENDING = "pending" # 최신 L0 가 담지 않은 커밋이 WAL 에 있다
51
+ IN_SYNC = "in_sync" # WAL 의 커밋이 모두 최신 L0 범위 안이다
52
+ NO_EVIDENCE = "none" # WAL 로는 판정할 수 없다
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class WalHeader:
57
+ little_endian: bool
58
+ page_size: int
59
+ salt1: int
60
+ salt2: int
61
+ checksum: tuple[int, int]
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class WalEvidence:
66
+ """``state`` 는 ``PENDING``·``IN_SYNC``·``NO_EVIDENCE``. ``reason`` 은 고정 문장이다.
67
+
68
+ ``ltx_end``·``commit_end`` 는 표시용 바이트 오프셋이다(``commit_end`` 는 PENDING 이면
69
+ L0 뒤 첫 커밋 프레임의 끝, IN_SYNC 면 ``None``).
70
+ """
71
+
72
+ state: str
73
+ reason: str
74
+ ltx_end: int | None = None
75
+ commit_end: int | None = None
76
+ salt_match: bool | None = None
77
+ key: object = None
78
+
79
+
80
+ def _checksum(little_endian: bool, s0: int, s1: int, data: bytes) -> tuple[int, int]:
81
+ words = struct.unpack(f"{'<' if little_endian else '>'}{len(data) // 4}I", data)
82
+ for i in range(0, len(words), 2):
83
+ s0 = (s0 + words[i] + s1) & 0xFFFFFFFF
84
+ s1 = (s1 + words[i + 1] + s0) & 0xFFFFFFFF
85
+ return s0, s1
86
+
87
+
88
+ def _open(path: str) -> int | None:
89
+ """정규 파일이면 읽기 전용 fd. 링크를 따라가지 않고 FIFO 에서 멈추지 않는다."""
90
+ try:
91
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
92
+ fd = os.open(path, flags)
93
+ except (OSError, ValueError):
94
+ return None
95
+ try:
96
+ if stat.S_ISREG(os.fstat(fd).st_mode):
97
+ return fd
98
+ except OSError:
99
+ pass
100
+ os.close(fd)
101
+ return None
102
+
103
+
104
+ def _pread(fd: int, size: int, offset: int) -> bytes:
105
+ data = b""
106
+ while len(data) < size:
107
+ chunk = os.pread(fd, size - len(data), offset + len(data))
108
+ if not chunk:
109
+ break
110
+ data += chunk
111
+ return data
112
+
113
+
114
+ def _parse_header(raw: bytes) -> WalHeader | None:
115
+ if len(raw) < WAL_HEADER_SIZE:
116
+ return None
117
+ magic, version, page_size, _seq, salt1, salt2, c1, c2 = struct.unpack(">8I", raw[:32])
118
+ if magic not in (_MAGIC_LE, _MAGIC_BE) or version != _VERSION:
119
+ return None
120
+ if page_size not in _PAGE_SIZES:
121
+ return None
122
+ little = magic == _MAGIC_LE
123
+ if _checksum(little, 0, 0, raw[:24]) != (c1, c2):
124
+ return None # 체크포인트 중 쓰다 만 헤더(Litestream readHeader 와 같은 처리)
125
+ return WalHeader(little, page_size, salt1, salt2, (c1, c2))
126
+
127
+
128
+ def read_wal_header(path: str) -> WalHeader | None:
129
+ fd = _open(path)
130
+ if fd is None:
131
+ return None
132
+ try:
133
+ return _parse_header(_pread(fd, WAL_HEADER_SIZE, 0))
134
+ except OSError:
135
+ return None
136
+ finally:
137
+ os.close(fd)
138
+
139
+
140
+ def _first_commit_after(
141
+ fd: int, header: WalHeader, offset: int, seed: tuple[int, int], limit: int
142
+ ) -> int | None | bool:
143
+ """``offset`` 부터 유효 프레임을 따라가 첫 커밋 프레임의 끝 오프셋.
144
+
145
+ 유효 프레임이 끝날 때까지 커밋이 없으면 ``None``, ``limit`` 을 넘으면 ``False``.
146
+ """
147
+ frame = FRAME_HEADER_SIZE + header.page_size
148
+ s0, s1 = seed
149
+ start = offset
150
+ while True:
151
+ if offset - start + frame > limit:
152
+ return False
153
+ raw = _pread(fd, frame, offset)
154
+ if len(raw) < frame:
155
+ return None
156
+ _pgno, commit, salt1, salt2, c1, c2 = struct.unpack(">6I", raw[:24])
157
+ if (salt1, salt2) != (header.salt1, header.salt2):
158
+ return None
159
+ s0, s1 = _checksum(header.little_endian, s0, s1, raw[:8])
160
+ s0, s1 = _checksum(header.little_endian, s0, s1, raw[24:])
161
+ if (s0, s1) != (c1, c2):
162
+ return None
163
+ offset += frame
164
+ if commit:
165
+ return offset
166
+
167
+
168
+ def _snapshot(fd: int) -> tuple[int, int, int, bytes]:
169
+ """처음 연 WAL 의 (st_dev, st_ino, 크기, 헤더 32바이트)."""
170
+ st = os.fstat(fd)
171
+ return st.st_dev, st.st_ino, st.st_size, _pread(fd, WAL_HEADER_SIZE, 0)
172
+
173
+
174
+ def _unchanged(fd: int, path: str, snap: tuple[int, int, int, bytes]) -> bool:
175
+ """읽는 동안 WAL 이 바뀌지 않았는가: 같은 경로가 같은 파일(dev, ino)이고, 크기와 헤더
176
+ (salt·체크섬 포함 32바이트)가 처음과 같다. 확인할 수 없으면 바뀐 것으로 본다."""
177
+ try:
178
+ st = os.fstat(fd)
179
+ at_path = os.lstat(path)
180
+ return (
181
+ (st.st_dev, st.st_ino, st.st_size) == snap[:3]
182
+ and (at_path.st_dev, at_path.st_ino) == snap[:2]
183
+ and _pread(fd, WAL_HEADER_SIZE, 0) == snap[3]
184
+ )
185
+ except (OSError, ValueError):
186
+ return False
187
+
188
+
189
+ def compare(wal_path: str, ltx: LtxWalRange | None, *, limit: int = SCAN_LIMIT) -> WalEvidence:
190
+ """현재 WAL 에 최신 L0 가 담지 않은 커밋이 있는가.
191
+
192
+ - 같은 salt: L0 끝(``WALOffset + WALSize``) 뒤에 유효한 커밋 프레임이 있으면 PENDING, 없으면
193
+ IN_SYNC.
194
+ - 다른 salt(L0 이후 WAL 이 다시 시작됨): 현재 salt 의 커밋 프레임이 있으면 PENDING(새 세대의
195
+ 커밋을 Litestream 이 아직 L0 로 쓰지 않았다), 없으면 NO_EVIDENCE(체크포인트·재시작 사이를
196
+ Litestream 이 관측했는지 알 수 없다).
197
+ - WAL 이 없거나 헤더를 읽을 수 없음, L0 에 WAL 정보가 없음, page size 불일치, L0 끝이
198
+ 프레임 경계가 아니거나 WAL 보다 김, 그 직전 프레임의 salt 가 다름 → NO_EVIDENCE.
199
+
200
+ 잠금 없이 읽으므로 읽는 사이에 체크포인트(TRUNCATE·RESTART)나 쓰기가 끼어들 수 있다.
201
+ Litestream 은 체크포인트를 막는 읽기 트랜잭션을 쥐고 읽지만 헬스는 DB 연결을 열지 않는다.
202
+ 예: L0 끝 직전 프레임을 읽은 직후 TRUNCATE 가 WAL 을 0바이트로 만들면 꼬리 스캔은 EOF 를
203
+ 보고 이미 있던 미복제 커밋을 "없음"으로 읽는다(review-3 재현). 그래서 PENDING 이 아닌 결과를
204
+ 돌려주기 전에 처음 연 WAL 의 (dev, ino)·크기·헤더를 다시 확인하고, 바뀌었으면 한 번만 다시
205
+ 읽는다. 다시 읽어도 바뀌면 NO_EVIDENCE("the -wal changed during read"). PENDING 은 L0 뒤의
206
+ 유효한 커밋 프레임을 이미 읽었다는 근거이므로 그대로 돌려준다.
207
+ """
208
+ if ltx is None:
209
+ return WalEvidence(NO_EVIDENCE, "the latest local L0 has no WAL position")
210
+ for _attempt in range(2):
211
+ result, stable = _compare_once(wal_path, ltx, limit)
212
+ if stable:
213
+ return result
214
+ return WalEvidence(NO_EVIDENCE, "the -wal changed during read", ltx.end)
215
+
216
+
217
+ def _compare_once(wal_path: str, ltx: LtxWalRange, limit: int) -> tuple[WalEvidence, bool]:
218
+ """한 번 읽는다. ``(결과, 믿을 수 있는가)``.
219
+
220
+ PENDING 이거나 읽는 동안 WAL 이 그대로였으면 참이다.
221
+ """
222
+ fd = _open(wal_path)
223
+ if fd is None:
224
+ return WalEvidence(NO_EVIDENCE, "no readable -wal file", ltx.end), True
225
+ try:
226
+ try:
227
+ snap = _snapshot(fd)
228
+ result = _evaluate(fd, snap[3], ltx, limit)
229
+ except OSError:
230
+ return WalEvidence(NO_EVIDENCE, "the -wal could not be read", ltx.end), False
231
+ if result.state == PENDING:
232
+ return result, True
233
+ return result, _unchanged(fd, wal_path, snap)
234
+ finally:
235
+ os.close(fd)
236
+
237
+
238
+ def _evaluate(fd: int, raw_header: bytes, ltx: LtxWalRange, limit: int) -> WalEvidence:
239
+ header = _parse_header(raw_header)
240
+ if header is None:
241
+ return WalEvidence(NO_EVIDENCE, "the -wal header is empty or not valid", ltx.end)
242
+ if header.page_size != ltx.page_size:
243
+ return WalEvidence(NO_EVIDENCE, "the -wal page size differs from the L0", ltx.end)
244
+ frame = FRAME_HEADER_SIZE + header.page_size
245
+ same = (header.salt1, header.salt2) == (ltx.salt1, ltx.salt2)
246
+ if not same:
247
+ end = _first_commit_after(fd, header, WAL_HEADER_SIZE, header.checksum, limit)
248
+ if end is False:
249
+ return WalEvidence(NO_EVIDENCE, "the -wal scan limit was reached", ltx.end)
250
+ if end is None:
251
+ return WalEvidence(
252
+ NO_EVIDENCE,
253
+ "the -wal restarted after the latest L0 and has no commit yet",
254
+ ltx.end,
255
+ salt_match=False,
256
+ )
257
+ return WalEvidence(
258
+ PENDING,
259
+ "the -wal restarted after the latest L0 and has commits not in it",
260
+ ltx.end,
261
+ end,
262
+ False,
263
+ )
264
+ if ltx.end == WAL_HEADER_SIZE:
265
+ seed = header.checksum
266
+ else:
267
+ if (ltx.end - WAL_HEADER_SIZE) % frame or ltx.end < WAL_HEADER_SIZE + frame:
268
+ return WalEvidence(NO_EVIDENCE, "the L0 WAL end is not on a frame boundary", ltx.end)
269
+ if os.fstat(fd).st_size < ltx.end:
270
+ return WalEvidence(NO_EVIDENCE, "the -wal is shorter than the L0 WAL end", ltx.end)
271
+ prev = _pread(fd, FRAME_HEADER_SIZE, ltx.end - frame)
272
+ if len(prev) < FRAME_HEADER_SIZE:
273
+ return WalEvidence(NO_EVIDENCE, "the -wal is shorter than the L0 WAL end", ltx.end)
274
+ _pgno, _commit, salt1, salt2, c1, c2 = struct.unpack(">6I", prev)
275
+ if (salt1, salt2) != (header.salt1, header.salt2):
276
+ return WalEvidence(
277
+ NO_EVIDENCE, "the -wal frame before the L0 WAL end was overwritten", ltx.end
278
+ )
279
+ seed = (c1, c2)
280
+ end = _first_commit_after(fd, header, ltx.end, seed, limit)
281
+ if end is False:
282
+ return WalEvidence(NO_EVIDENCE, "the -wal scan limit was reached", ltx.end)
283
+ if end is None:
284
+ return WalEvidence(
285
+ IN_SYNC, "every -wal commit is within the latest L0", ltx.end, None, True
286
+ )
287
+ return WalEvidence(PENDING, "the -wal has commits after the latest L0", ltx.end, end, True)