sediment-cli 0.1.0__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.
- sediment_cli/__init__.py +22 -0
- sediment_cli/attribution.py +3881 -0
- sediment_cli/cli.py +1922 -0
- sediment_cli/client.py +323 -0
- sediment_cli/delivery.py +1334 -0
- sediment_cli/local_postgres.py +337 -0
- sediment_cli/transcript.py +1764 -0
- sediment_cli/ui.py +123 -0
- sediment_cli-0.1.0.dist-info/METADATA +16 -0
- sediment_cli-0.1.0.dist-info/RECORD +13 -0
- sediment_cli-0.1.0.dist-info/WHEEL +4 -0
- sediment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- sediment_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
sediment_cli/delivery.py
ADDED
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Bounded, opt-in transport of immutable prepared HTTP bodies (ADR 0017).
|
|
3
|
+
|
|
4
|
+
This module is Python 3.12 stdlib-only so a standalone copy is also a supported
|
|
5
|
+
gateway/fleet implementation. Transport receipts never establish OTLP Fact counts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
13
|
+
import fcntl
|
|
14
|
+
import hashlib
|
|
15
|
+
import http.client
|
|
16
|
+
import ipaddress
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import secrets
|
|
23
|
+
import signal
|
|
24
|
+
import socket
|
|
25
|
+
import ssl
|
|
26
|
+
import stat
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import uuid
|
|
32
|
+
from collections import Counter
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
from dataclasses import dataclass, replace
|
|
35
|
+
from datetime import UTC, datetime
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Mapping
|
|
38
|
+
|
|
39
|
+
FORMAT_VERSION = 1
|
|
40
|
+
MAX_ACTIVE_BYTES = 256 * 1024 * 1024
|
|
41
|
+
MAX_ACTIVE_ENTRIES = 2048
|
|
42
|
+
MAX_ENTRY_BYTES = 8 * 1024 * 1024
|
|
43
|
+
MAX_STDIN_BYTES = ((MAX_ENTRY_BYTES + 2) // 3) * 4 + 16384
|
|
44
|
+
REPLAY_WINDOW_SECONDS = 24 * 60 * 60
|
|
45
|
+
RECEIPT_RETENTION_SECONDS = 7 * 24 * 60 * 60
|
|
46
|
+
MAX_RECEIPTS = 2048
|
|
47
|
+
MAX_BATCH_ATTEMPTS = 32
|
|
48
|
+
HTTP_TIMEOUT_SECONDS = 5
|
|
49
|
+
MAX_BACKOFF_SECONDS = 60
|
|
50
|
+
MAX_RESPONSE_BYTES = 65536
|
|
51
|
+
_MAX_HEADER_BYTES = 16384
|
|
52
|
+
_TERMINAL = {"acknowledged", "skipped", "declined"}
|
|
53
|
+
_REASONS = {
|
|
54
|
+
"buffered",
|
|
55
|
+
"already_buffered",
|
|
56
|
+
"gateway_stored",
|
|
57
|
+
"gateway_duplicate",
|
|
58
|
+
"otlp_delivered",
|
|
59
|
+
"no_session",
|
|
60
|
+
"invalid_acknowledgment",
|
|
61
|
+
"transport_failure",
|
|
62
|
+
"retryable_http",
|
|
63
|
+
"authentication_rejected",
|
|
64
|
+
"client_rejected",
|
|
65
|
+
"redirect_rejected",
|
|
66
|
+
"configuration_missing",
|
|
67
|
+
"configuration_invalid",
|
|
68
|
+
"destination_changed",
|
|
69
|
+
"credentials_missing",
|
|
70
|
+
"credentials_invalid",
|
|
71
|
+
"buffer_full",
|
|
72
|
+
"entry_too_large",
|
|
73
|
+
"unsafe_storage",
|
|
74
|
+
"storage_unavailable",
|
|
75
|
+
"storage_busy",
|
|
76
|
+
"worker_busy",
|
|
77
|
+
"expired",
|
|
78
|
+
"identity_conflict",
|
|
79
|
+
"invalid_record",
|
|
80
|
+
"invalid_request",
|
|
81
|
+
"buffer_disabled",
|
|
82
|
+
}
|
|
83
|
+
logger = logging.getLogger("sediment.delivery")
|
|
84
|
+
# A timed-out OS resolver may finish later; only one resolver can remain alive.
|
|
85
|
+
_DNS_LOCK = threading.Lock()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DeliveryError(ValueError):
|
|
89
|
+
"""A closed, content-free transport failure."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, reason: str):
|
|
92
|
+
super().__init__(reason)
|
|
93
|
+
self.reason = reason
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Disposition:
|
|
98
|
+
delivery_id: str
|
|
99
|
+
status: str
|
|
100
|
+
reason: str
|
|
101
|
+
fact_id: str | None = None
|
|
102
|
+
stored: bool | None = None
|
|
103
|
+
fallback_reason: str | None = None
|
|
104
|
+
|
|
105
|
+
def as_dict(self) -> dict:
|
|
106
|
+
result = {
|
|
107
|
+
"format_version": FORMAT_VERSION,
|
|
108
|
+
"delivery_id": self.delivery_id,
|
|
109
|
+
"status": self.status,
|
|
110
|
+
"reason": self.reason,
|
|
111
|
+
}
|
|
112
|
+
if self.fact_id is not None:
|
|
113
|
+
result.update(fact_id=self.fact_id, stored=self.stored)
|
|
114
|
+
if self.fallback_reason is not None:
|
|
115
|
+
result["fallback_reason"] = self.fallback_reason
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class DeliveryRequest:
|
|
121
|
+
channel: str
|
|
122
|
+
destination: str
|
|
123
|
+
body: bytes
|
|
124
|
+
captured_at: str
|
|
125
|
+
delivery_id: str
|
|
126
|
+
|
|
127
|
+
def as_dict(self) -> dict:
|
|
128
|
+
return {
|
|
129
|
+
"channel": self.channel,
|
|
130
|
+
"destination": self.destination,
|
|
131
|
+
"body_base64": base64.b64encode(self.body).decode("ascii"),
|
|
132
|
+
"captured_at": self.captured_at,
|
|
133
|
+
"delivery_id": self.delivery_id,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _uuid(value: str) -> str:
|
|
138
|
+
if not isinstance(value, str):
|
|
139
|
+
raise DeliveryError("invalid_request")
|
|
140
|
+
try:
|
|
141
|
+
return str(uuid.UUID(value))
|
|
142
|
+
except ValueError:
|
|
143
|
+
raise DeliveryError("invalid_request") from None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _instant(value: str) -> str:
|
|
147
|
+
if not isinstance(value, str):
|
|
148
|
+
raise DeliveryError("invalid_request")
|
|
149
|
+
try:
|
|
150
|
+
instant = datetime.fromisoformat(value)
|
|
151
|
+
if instant.tzinfo is None or instant.utcoffset() is None:
|
|
152
|
+
raise ValueError
|
|
153
|
+
return instant.astimezone(UTC).isoformat()
|
|
154
|
+
except (ValueError, OverflowError):
|
|
155
|
+
raise DeliveryError("invalid_request") from None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _destination(channel: str, value: str) -> str:
|
|
159
|
+
if (
|
|
160
|
+
not isinstance(channel, str)
|
|
161
|
+
or channel not in {"gateway", "otlp"}
|
|
162
|
+
or not isinstance(value, str)
|
|
163
|
+
):
|
|
164
|
+
raise DeliveryError("invalid_request")
|
|
165
|
+
if len(value) > 2048:
|
|
166
|
+
raise DeliveryError("invalid_request")
|
|
167
|
+
value = value.strip().rstrip("/")
|
|
168
|
+
if not value or any(ord(c) < 33 or ord(c) == 127 for c in value):
|
|
169
|
+
raise DeliveryError("invalid_request")
|
|
170
|
+
try:
|
|
171
|
+
parsed = urllib.parse.urlsplit(value)
|
|
172
|
+
host, port = parsed.hostname, parsed.port
|
|
173
|
+
if (
|
|
174
|
+
parsed.scheme not in {"http", "https"}
|
|
175
|
+
or not host
|
|
176
|
+
or parsed.username is not None
|
|
177
|
+
or parsed.password is not None
|
|
178
|
+
or "?" in value
|
|
179
|
+
or "#" in value
|
|
180
|
+
or parsed.netloc.endswith(":")
|
|
181
|
+
or "\\" in value
|
|
182
|
+
or port == 0
|
|
183
|
+
):
|
|
184
|
+
raise ValueError
|
|
185
|
+
path = "/ingest/gateway" if channel == "gateway" else "/v1/logs"
|
|
186
|
+
if parsed.path not in {"", "/", path}:
|
|
187
|
+
raise ValueError
|
|
188
|
+
if channel == "otlp" and parsed.scheme == "http" and host != "localhost":
|
|
189
|
+
if not ipaddress.ip_address(host).is_loopback:
|
|
190
|
+
raise ValueError
|
|
191
|
+
host.encode("ascii")
|
|
192
|
+
authority = f"[{host}]" if ":" in host else host
|
|
193
|
+
if port is not None:
|
|
194
|
+
authority += f":{port}"
|
|
195
|
+
return urllib.parse.urlunsplit((parsed.scheme, authority, path, "", ""))
|
|
196
|
+
except (ValueError, UnicodeError):
|
|
197
|
+
raise DeliveryError("invalid_request") from None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def prepare_request(
|
|
201
|
+
channel: str,
|
|
202
|
+
destination: str,
|
|
203
|
+
body: bytes,
|
|
204
|
+
*,
|
|
205
|
+
captured_at: str | None = None,
|
|
206
|
+
delivery_id: str | None = None,
|
|
207
|
+
) -> DeliveryRequest:
|
|
208
|
+
if not isinstance(body, bytes):
|
|
209
|
+
raise DeliveryError("invalid_request")
|
|
210
|
+
if len(body) > MAX_ENTRY_BYTES:
|
|
211
|
+
raise DeliveryError("entry_too_large")
|
|
212
|
+
return DeliveryRequest(
|
|
213
|
+
channel=channel,
|
|
214
|
+
destination=_destination(channel, destination),
|
|
215
|
+
body=body,
|
|
216
|
+
captured_at=_instant(
|
|
217
|
+
captured_at if captured_at is not None else datetime.now(UTC).isoformat()
|
|
218
|
+
),
|
|
219
|
+
delivery_id=_uuid(delivery_id)
|
|
220
|
+
if delivery_id is not None
|
|
221
|
+
else str(uuid.uuid4()),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validated(request: DeliveryRequest) -> DeliveryRequest:
|
|
226
|
+
if not isinstance(request, DeliveryRequest):
|
|
227
|
+
raise DeliveryError("invalid_request")
|
|
228
|
+
return prepare_request(
|
|
229
|
+
request.channel,
|
|
230
|
+
request.destination,
|
|
231
|
+
request.body,
|
|
232
|
+
captured_at=request.captured_at,
|
|
233
|
+
delivery_id=request.delivery_id,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _report(disposition: Disposition) -> Disposition:
|
|
238
|
+
logger.warning(
|
|
239
|
+
"delivery_disposition delivery_id=%s status=%s reason=%s",
|
|
240
|
+
disposition.delivery_id,
|
|
241
|
+
disposition.status,
|
|
242
|
+
disposition.reason,
|
|
243
|
+
)
|
|
244
|
+
return disposition
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _json(data: bytes | str):
|
|
248
|
+
def object_pairs(pairs):
|
|
249
|
+
result = {}
|
|
250
|
+
for key, value in pairs:
|
|
251
|
+
if key in result:
|
|
252
|
+
raise ValueError
|
|
253
|
+
result[key] = value
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
def constant(value):
|
|
257
|
+
raise ValueError
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return json.loads(data, object_pairs_hook=object_pairs, parse_constant=constant)
|
|
261
|
+
except RecursionError:
|
|
262
|
+
raise ValueError from None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _encoded(value) -> bytes:
|
|
266
|
+
return json.dumps(
|
|
267
|
+
value, ensure_ascii=True, allow_nan=False, separators=(",", ":"), sort_keys=True
|
|
268
|
+
).encode()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def configured_destination(channel: str, env: Mapping[str, str] | None = None) -> str:
|
|
272
|
+
env = os.environ if env is None else env
|
|
273
|
+
name = "SEDIMENT_INGEST_URL" if channel == "gateway" else "SEDIMENT_OTLP_ENDPOINT"
|
|
274
|
+
value = env.get(name)
|
|
275
|
+
if not value:
|
|
276
|
+
raise DeliveryError("configuration_missing")
|
|
277
|
+
try:
|
|
278
|
+
destination = _destination(channel, value)
|
|
279
|
+
parsed = urllib.parse.urlsplit(destination)
|
|
280
|
+
if channel == "gateway" and parsed.scheme == "http":
|
|
281
|
+
try:
|
|
282
|
+
loopback = ipaddress.ip_address(parsed.hostname).is_loopback
|
|
283
|
+
except ValueError:
|
|
284
|
+
loopback = parsed.hostname == "localhost"
|
|
285
|
+
if not loopback:
|
|
286
|
+
origin = env.get("SEDIMENT_GATEWAY_LOCAL_HTTP_ORIGIN", "")
|
|
287
|
+
# Only this active, exact origin authorizes plaintext gateway
|
|
288
|
+
# delivery. Prepared records never store or enable the exception.
|
|
289
|
+
if any(ord(c) < 33 or ord(c) == 127 for c in origin):
|
|
290
|
+
raise DeliveryError("configuration_invalid")
|
|
291
|
+
allowed = urllib.parse.urlsplit(_destination("gateway", origin))
|
|
292
|
+
raw = urllib.parse.urlsplit(origin)
|
|
293
|
+
if (
|
|
294
|
+
raw.path not in {"", "/"}
|
|
295
|
+
or allowed.scheme != "http"
|
|
296
|
+
or (parsed.scheme, parsed.hostname, parsed.port)
|
|
297
|
+
!= (allowed.scheme, allowed.hostname, allowed.port)
|
|
298
|
+
):
|
|
299
|
+
raise DeliveryError("configuration_invalid")
|
|
300
|
+
return destination
|
|
301
|
+
except DeliveryError:
|
|
302
|
+
raise DeliveryError("configuration_invalid") from None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _credential(channel: str, env: Mapping[str, str]) -> str:
|
|
306
|
+
token = env.get(
|
|
307
|
+
"SEDIMENT_API_BEARER_TOKEN"
|
|
308
|
+
if channel == "gateway"
|
|
309
|
+
else "SEDIMENT_INGEST_TOKEN",
|
|
310
|
+
"",
|
|
311
|
+
)
|
|
312
|
+
if not token and channel == "otlp":
|
|
313
|
+
for field in env.get("OTEL_EXPORTER_OTLP_HEADERS", "").split(","):
|
|
314
|
+
key, separator, value = field.partition("=")
|
|
315
|
+
if separator and key.strip().lower() == "authorization":
|
|
316
|
+
value = urllib.parse.unquote(value).strip()
|
|
317
|
+
if value.lower().startswith("bearer "):
|
|
318
|
+
token = value[7:]
|
|
319
|
+
break
|
|
320
|
+
if not token:
|
|
321
|
+
raise DeliveryError("credentials_missing")
|
|
322
|
+
if not isinstance(token, str) or any(ord(c) < 32 or ord(c) > 126 for c in token):
|
|
323
|
+
raise DeliveryError("credentials_invalid")
|
|
324
|
+
return token
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _configuration(request: DeliveryRequest, env: Mapping[str, str]) -> str:
|
|
328
|
+
if configured_destination(request.channel, env) != request.destination:
|
|
329
|
+
raise DeliveryError("destination_changed")
|
|
330
|
+
return _credential(request.channel, env)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _acknowledgment(request: DeliveryRequest, body: bytes) -> Disposition:
|
|
334
|
+
invalid = Disposition(request.delivery_id, "pending", "invalid_acknowledgment")
|
|
335
|
+
try:
|
|
336
|
+
result = _json(body)
|
|
337
|
+
except (ValueError, UnicodeError):
|
|
338
|
+
return invalid
|
|
339
|
+
if not isinstance(result, dict):
|
|
340
|
+
return invalid
|
|
341
|
+
if request.channel == "otlp":
|
|
342
|
+
return (
|
|
343
|
+
Disposition(request.delivery_id, "acknowledged", "otlp_delivered")
|
|
344
|
+
if result == {}
|
|
345
|
+
else invalid
|
|
346
|
+
)
|
|
347
|
+
if set(result) == {"fact_id", "stored"}:
|
|
348
|
+
fact_id = result["fact_id"]
|
|
349
|
+
if (
|
|
350
|
+
isinstance(fact_id, str)
|
|
351
|
+
and fact_id.strip() == fact_id
|
|
352
|
+
and fact_id
|
|
353
|
+
and len(fact_id) <= 1024
|
|
354
|
+
and not any(ord(c) < 32 for c in fact_id)
|
|
355
|
+
and type(result["stored"]) is bool
|
|
356
|
+
):
|
|
357
|
+
return Disposition(
|
|
358
|
+
request.delivery_id,
|
|
359
|
+
"acknowledged",
|
|
360
|
+
"gateway_stored" if result["stored"] else "gateway_duplicate",
|
|
361
|
+
fact_id,
|
|
362
|
+
result["stored"],
|
|
363
|
+
)
|
|
364
|
+
if (
|
|
365
|
+
set(result) == {"skipped", "reason"}
|
|
366
|
+
and result["skipped"] is True
|
|
367
|
+
and result["reason"] == "no_session"
|
|
368
|
+
):
|
|
369
|
+
return Disposition(request.delivery_id, "skipped", "no_session")
|
|
370
|
+
return invalid
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _remaining(deadline: float) -> float:
|
|
374
|
+
remaining = deadline - time.monotonic()
|
|
375
|
+
if remaining <= 0:
|
|
376
|
+
raise TimeoutError
|
|
377
|
+
return remaining
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _connect(host: str, port: int, *, secure: bool, deadline: float):
|
|
381
|
+
# socket's timeout excludes DNS. Bound the caller's wait and the number of
|
|
382
|
+
# outstanding OS lookups; a late resolver never receives or sends a payload.
|
|
383
|
+
if not _DNS_LOCK.acquire(timeout=_remaining(deadline)):
|
|
384
|
+
raise TimeoutError
|
|
385
|
+
done = threading.Event()
|
|
386
|
+
addresses, errors = [], []
|
|
387
|
+
|
|
388
|
+
def resolve():
|
|
389
|
+
try:
|
|
390
|
+
addresses.extend(socket.getaddrinfo(host, port, type=socket.SOCK_STREAM))
|
|
391
|
+
except OSError as exc:
|
|
392
|
+
errors.append(exc)
|
|
393
|
+
finally:
|
|
394
|
+
_DNS_LOCK.release()
|
|
395
|
+
done.set()
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
threading.Thread(target=resolve, daemon=True).start()
|
|
399
|
+
except RuntimeError:
|
|
400
|
+
_DNS_LOCK.release()
|
|
401
|
+
raise OSError from None
|
|
402
|
+
if not done.wait(_remaining(deadline)):
|
|
403
|
+
raise TimeoutError
|
|
404
|
+
if errors:
|
|
405
|
+
raise errors[0]
|
|
406
|
+
for family, kind, protocol, _, address in addresses:
|
|
407
|
+
connection = socket.socket(family, kind, protocol)
|
|
408
|
+
try:
|
|
409
|
+
connection.settimeout(_remaining(deadline))
|
|
410
|
+
connection.connect(address)
|
|
411
|
+
if secure:
|
|
412
|
+
context = ssl.create_default_context()
|
|
413
|
+
connection.settimeout(_remaining(deadline))
|
|
414
|
+
connection = context.wrap_socket(connection, server_hostname=host)
|
|
415
|
+
return connection
|
|
416
|
+
except OSError:
|
|
417
|
+
connection.close()
|
|
418
|
+
raise OSError
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def send_once(
|
|
422
|
+
request: DeliveryRequest, *, env: Mapping[str, str] | None = None
|
|
423
|
+
) -> Disposition:
|
|
424
|
+
"""One bounded HTTP attempt; no redirects, retries, or stored credentials."""
|
|
425
|
+
env = os.environ if env is None else env
|
|
426
|
+
delivery_id = str(uuid.uuid4())
|
|
427
|
+
try:
|
|
428
|
+
request = _validated(request)
|
|
429
|
+
delivery_id = request.delivery_id
|
|
430
|
+
token = _configuration(request, env)
|
|
431
|
+
except DeliveryError as exc:
|
|
432
|
+
return _report(Disposition(delivery_id, "blocked", exc.reason))
|
|
433
|
+
parsed = urllib.parse.urlsplit(request.destination)
|
|
434
|
+
connection_type = (
|
|
435
|
+
http.client.HTTPSConnection
|
|
436
|
+
if parsed.scheme == "https"
|
|
437
|
+
else http.client.HTTPConnection
|
|
438
|
+
)
|
|
439
|
+
connection = connection_type(
|
|
440
|
+
parsed.hostname, parsed.port, timeout=HTTP_TIMEOUT_SECONDS
|
|
441
|
+
)
|
|
442
|
+
timer = None
|
|
443
|
+
try:
|
|
444
|
+
deadline = time.monotonic() + HTTP_TIMEOUT_SECONDS
|
|
445
|
+
transport = _connect(
|
|
446
|
+
parsed.hostname,
|
|
447
|
+
parsed.port or (443 if parsed.scheme == "https" else 80),
|
|
448
|
+
secure=parsed.scheme == "https",
|
|
449
|
+
deadline=deadline,
|
|
450
|
+
)
|
|
451
|
+
connection.sock = transport
|
|
452
|
+
|
|
453
|
+
def interrupt():
|
|
454
|
+
try:
|
|
455
|
+
transport.shutdown(socket.SHUT_RDWR)
|
|
456
|
+
except OSError:
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
# An inactivity timeout alone permits an endless slow response/header.
|
|
460
|
+
timer = threading.Timer(_remaining(deadline), interrupt)
|
|
461
|
+
timer.daemon = True
|
|
462
|
+
timer.start()
|
|
463
|
+
connection.request(
|
|
464
|
+
"POST",
|
|
465
|
+
parsed.path,
|
|
466
|
+
body=request.body,
|
|
467
|
+
headers={
|
|
468
|
+
"Content-Type": "application/json",
|
|
469
|
+
"User-Agent": "sediment-delivery/1",
|
|
470
|
+
"Authorization": f"Bearer {token}",
|
|
471
|
+
},
|
|
472
|
+
)
|
|
473
|
+
response = connection.getresponse()
|
|
474
|
+
code = response.status
|
|
475
|
+
if code in {408, 429} or 500 <= code <= 599:
|
|
476
|
+
result = Disposition(delivery_id, "pending", "retryable_http")
|
|
477
|
+
elif code in {401, 403}:
|
|
478
|
+
result = Disposition(delivery_id, "blocked", "authentication_rejected")
|
|
479
|
+
elif 300 <= code <= 399:
|
|
480
|
+
result = Disposition(delivery_id, "blocked", "redirect_rejected")
|
|
481
|
+
elif 400 <= code <= 499:
|
|
482
|
+
result = Disposition(delivery_id, "blocked", "client_rejected")
|
|
483
|
+
elif code != 200:
|
|
484
|
+
result = Disposition(delivery_id, "pending", "invalid_acknowledgment")
|
|
485
|
+
else:
|
|
486
|
+
body = bytearray()
|
|
487
|
+
while len(body) <= MAX_RESPONSE_BYTES:
|
|
488
|
+
remaining = deadline - time.monotonic()
|
|
489
|
+
if remaining <= 0:
|
|
490
|
+
raise TimeoutError
|
|
491
|
+
if connection.sock is not None:
|
|
492
|
+
connection.sock.settimeout(remaining)
|
|
493
|
+
part = response.read1(min(8192, MAX_RESPONSE_BYTES + 1 - len(body)))
|
|
494
|
+
if not part:
|
|
495
|
+
break
|
|
496
|
+
body.extend(part)
|
|
497
|
+
_remaining(deadline)
|
|
498
|
+
if response.length not in {None, 0} and len(body) <= MAX_RESPONSE_BYTES:
|
|
499
|
+
raise http.client.IncompleteRead(bytes(body))
|
|
500
|
+
result = (
|
|
501
|
+
_acknowledgment(request, bytes(body))
|
|
502
|
+
if len(body) <= MAX_RESPONSE_BYTES
|
|
503
|
+
else Disposition(delivery_id, "pending", "invalid_acknowledgment")
|
|
504
|
+
)
|
|
505
|
+
except (OSError, http.client.HTTPException, ValueError):
|
|
506
|
+
result = Disposition(delivery_id, "pending", "transport_failure")
|
|
507
|
+
finally:
|
|
508
|
+
if timer is not None:
|
|
509
|
+
timer.cancel()
|
|
510
|
+
connection.close()
|
|
511
|
+
return _report(result)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class _Queue:
|
|
515
|
+
"""An opened private directory; relative operations cannot follow record links."""
|
|
516
|
+
|
|
517
|
+
def __init__(self, path: str | Path, *, create: bool):
|
|
518
|
+
self.path = Path(path)
|
|
519
|
+
self.fd = -1
|
|
520
|
+
if not self.path.is_absolute():
|
|
521
|
+
raise DeliveryError("unsafe_storage")
|
|
522
|
+
try:
|
|
523
|
+
self.fd = self._open_directory(create=create)
|
|
524
|
+
self._check(os.fstat(self.fd), directory=True)
|
|
525
|
+
for name in os.listdir(self.fd):
|
|
526
|
+
try:
|
|
527
|
+
self._check(os.stat(name, dir_fd=self.fd, follow_symlinks=False))
|
|
528
|
+
except FileNotFoundError:
|
|
529
|
+
# Another publisher/worker may have renamed or removed it.
|
|
530
|
+
pass
|
|
531
|
+
except Exception:
|
|
532
|
+
if self.fd != -1:
|
|
533
|
+
os.close(self.fd)
|
|
534
|
+
self.fd = -1
|
|
535
|
+
raise
|
|
536
|
+
|
|
537
|
+
def _open_directory(self, *, create: bool) -> int:
|
|
538
|
+
parts = list(self.path.parts[1:])
|
|
539
|
+
if ".." in parts:
|
|
540
|
+
raise DeliveryError("unsafe_storage")
|
|
541
|
+
# macOS exposes these root-owned OS aliases. All remaining components,
|
|
542
|
+
# including user-created ancestor links, are opened without following links.
|
|
543
|
+
if sys.platform == "darwin" and parts and parts[0] in {"tmp", "var"}:
|
|
544
|
+
alias = Path("/") / parts[0]
|
|
545
|
+
info = alias.lstat()
|
|
546
|
+
if (
|
|
547
|
+
stat.S_ISLNK(info.st_mode)
|
|
548
|
+
and info.st_uid == 0
|
|
549
|
+
and os.readlink(alias) == "private/" + parts[0]
|
|
550
|
+
):
|
|
551
|
+
parts.insert(0, "private")
|
|
552
|
+
directory = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
553
|
+
try:
|
|
554
|
+
for index, part in enumerate(parts):
|
|
555
|
+
try:
|
|
556
|
+
child = os.open(
|
|
557
|
+
part,
|
|
558
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
559
|
+
dir_fd=directory,
|
|
560
|
+
)
|
|
561
|
+
except FileNotFoundError:
|
|
562
|
+
if not create:
|
|
563
|
+
raise
|
|
564
|
+
try:
|
|
565
|
+
os.mkdir(part, 0o700, dir_fd=directory)
|
|
566
|
+
os.fsync(directory)
|
|
567
|
+
except FileExistsError:
|
|
568
|
+
pass
|
|
569
|
+
child = os.open(
|
|
570
|
+
part,
|
|
571
|
+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
|
|
572
|
+
dir_fd=directory,
|
|
573
|
+
)
|
|
574
|
+
os.close(directory)
|
|
575
|
+
directory = child
|
|
576
|
+
info = os.fstat(directory)
|
|
577
|
+
if index != len(parts) - 1 and (
|
|
578
|
+
info.st_uid not in {0, os.getuid()}
|
|
579
|
+
or (
|
|
580
|
+
stat.S_IMODE(info.st_mode) & 0o022
|
|
581
|
+
and not (info.st_uid == 0 and info.st_mode & stat.S_ISVTX)
|
|
582
|
+
)
|
|
583
|
+
):
|
|
584
|
+
raise DeliveryError("unsafe_storage")
|
|
585
|
+
return directory
|
|
586
|
+
except Exception:
|
|
587
|
+
os.close(directory)
|
|
588
|
+
raise
|
|
589
|
+
|
|
590
|
+
@staticmethod
|
|
591
|
+
def _check(info, *, directory=False):
|
|
592
|
+
correct_type = (
|
|
593
|
+
stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
|
|
594
|
+
)
|
|
595
|
+
mode = 0o700 if directory else 0o600
|
|
596
|
+
if (
|
|
597
|
+
not correct_type
|
|
598
|
+
or info.st_uid != os.getuid()
|
|
599
|
+
or stat.S_IMODE(info.st_mode) != mode
|
|
600
|
+
or (not directory and info.st_nlink != 1)
|
|
601
|
+
):
|
|
602
|
+
raise DeliveryError("unsafe_storage")
|
|
603
|
+
|
|
604
|
+
def __enter__(self):
|
|
605
|
+
return self
|
|
606
|
+
|
|
607
|
+
def __exit__(self, *args):
|
|
608
|
+
os.close(self.fd)
|
|
609
|
+
|
|
610
|
+
def read(self, name: str, limit: int) -> bytes:
|
|
611
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=self.fd)
|
|
612
|
+
with os.fdopen(fd, "rb") as stream:
|
|
613
|
+
self._check(os.fstat(stream.fileno()))
|
|
614
|
+
data = stream.read(limit + 1)
|
|
615
|
+
if len(data) > limit:
|
|
616
|
+
raise DeliveryError("invalid_record")
|
|
617
|
+
return data
|
|
618
|
+
|
|
619
|
+
def atomic(self, name: str, data: bytes):
|
|
620
|
+
temporary = f".tmp-{uuid.uuid4()}"
|
|
621
|
+
fd = os.open(
|
|
622
|
+
temporary,
|
|
623
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
624
|
+
0o600,
|
|
625
|
+
dir_fd=self.fd,
|
|
626
|
+
)
|
|
627
|
+
try:
|
|
628
|
+
with os.fdopen(fd, "wb") as stream:
|
|
629
|
+
stream.write(data)
|
|
630
|
+
stream.flush()
|
|
631
|
+
os.fsync(stream.fileno())
|
|
632
|
+
os.rename(temporary, name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
|
|
633
|
+
os.fsync(self.fd)
|
|
634
|
+
finally:
|
|
635
|
+
self.remove(temporary)
|
|
636
|
+
|
|
637
|
+
def remove(self, name):
|
|
638
|
+
try:
|
|
639
|
+
os.unlink(name, dir_fd=self.fd)
|
|
640
|
+
except FileNotFoundError:
|
|
641
|
+
pass
|
|
642
|
+
|
|
643
|
+
@contextmanager
|
|
644
|
+
def lock(self, name: str, *, wait: bool = True, create: bool = True):
|
|
645
|
+
flags = os.O_RDWR | os.O_NOFOLLOW | (os.O_CREAT if create else 0)
|
|
646
|
+
fd = os.open(name, flags, 0o600, dir_fd=self.fd)
|
|
647
|
+
try:
|
|
648
|
+
self._check(os.fstat(fd))
|
|
649
|
+
deadline = time.monotonic() + 1
|
|
650
|
+
while True:
|
|
651
|
+
try:
|
|
652
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
653
|
+
break
|
|
654
|
+
except BlockingIOError:
|
|
655
|
+
if not wait or time.monotonic() >= deadline:
|
|
656
|
+
raise DeliveryError(
|
|
657
|
+
"worker_busy" if name == "worker.lock" else "storage_busy"
|
|
658
|
+
) from None
|
|
659
|
+
time.sleep(0.01)
|
|
660
|
+
yield
|
|
661
|
+
finally:
|
|
662
|
+
os.close(fd)
|
|
663
|
+
|
|
664
|
+
def names(self, suffix):
|
|
665
|
+
return sorted(name for name in os.listdir(self.fd) if name.endswith(suffix))
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _failure(exc: Exception) -> str:
|
|
669
|
+
if isinstance(exc, DeliveryError):
|
|
670
|
+
return exc.reason
|
|
671
|
+
if isinstance(exc, OSError) and exc.errno in {40, 62, 20}:
|
|
672
|
+
return "unsafe_storage"
|
|
673
|
+
return "storage_unavailable"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _header(queue: _Queue, name: str) -> dict:
|
|
677
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=queue.fd)
|
|
678
|
+
with os.fdopen(fd, "rb") as stream:
|
|
679
|
+
info = os.fstat(stream.fileno())
|
|
680
|
+
queue._check(info)
|
|
681
|
+
line = stream.readline(_MAX_HEADER_BYTES + 1)
|
|
682
|
+
try:
|
|
683
|
+
header = _json(line)
|
|
684
|
+
required = {
|
|
685
|
+
"format_version",
|
|
686
|
+
"delivery_id",
|
|
687
|
+
"channel",
|
|
688
|
+
"destination",
|
|
689
|
+
"captured_at",
|
|
690
|
+
"enqueued_at",
|
|
691
|
+
"bytes",
|
|
692
|
+
"sha256",
|
|
693
|
+
}
|
|
694
|
+
if (
|
|
695
|
+
not line.endswith(b"\n")
|
|
696
|
+
or len(line) > _MAX_HEADER_BYTES
|
|
697
|
+
or not isinstance(header, dict)
|
|
698
|
+
or set(header) != required
|
|
699
|
+
):
|
|
700
|
+
raise ValueError
|
|
701
|
+
if (
|
|
702
|
+
type(header["format_version"]) is not int
|
|
703
|
+
or header["format_version"] != FORMAT_VERSION
|
|
704
|
+
):
|
|
705
|
+
raise ValueError
|
|
706
|
+
if (
|
|
707
|
+
_uuid(header["delivery_id"]) + ".entry" != name
|
|
708
|
+
or _destination(header["channel"], header["destination"])
|
|
709
|
+
!= header["destination"]
|
|
710
|
+
):
|
|
711
|
+
raise ValueError
|
|
712
|
+
_instant(header["captured_at"])
|
|
713
|
+
if (
|
|
714
|
+
not _number(header["enqueued_at"])
|
|
715
|
+
or type(header["bytes"]) is not int
|
|
716
|
+
or not 0 <= header["bytes"] <= MAX_ENTRY_BYTES
|
|
717
|
+
):
|
|
718
|
+
raise ValueError
|
|
719
|
+
if (
|
|
720
|
+
info.st_size != len(line) + header["bytes"]
|
|
721
|
+
or not isinstance(header["sha256"], str)
|
|
722
|
+
or not re.fullmatch("[0-9a-f]{64}", header["sha256"])
|
|
723
|
+
):
|
|
724
|
+
raise ValueError
|
|
725
|
+
except (ValueError, TypeError, KeyError):
|
|
726
|
+
raise DeliveryError("invalid_record") from None
|
|
727
|
+
return header
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _number(value):
|
|
731
|
+
try:
|
|
732
|
+
return type(value) in {int, float} and math.isfinite(value) and value >= 0
|
|
733
|
+
except OverflowError:
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _request(queue: _Queue, header: dict) -> DeliveryRequest:
|
|
738
|
+
data = queue.read(
|
|
739
|
+
header["delivery_id"] + ".entry", MAX_ENTRY_BYTES + _MAX_HEADER_BYTES
|
|
740
|
+
)
|
|
741
|
+
_, _, body = data.partition(b"\n")
|
|
742
|
+
if (
|
|
743
|
+
len(body) != header["bytes"]
|
|
744
|
+
or hashlib.sha256(body).hexdigest() != header["sha256"]
|
|
745
|
+
):
|
|
746
|
+
raise DeliveryError("invalid_record")
|
|
747
|
+
return prepare_request(
|
|
748
|
+
header["channel"],
|
|
749
|
+
header["destination"],
|
|
750
|
+
body,
|
|
751
|
+
captured_at=header["captured_at"],
|
|
752
|
+
delivery_id=header["delivery_id"],
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _state(queue: _Queue, delivery_id: str) -> dict:
|
|
757
|
+
try:
|
|
758
|
+
state = _json(queue.read(delivery_id + ".state", _MAX_HEADER_BYTES))
|
|
759
|
+
except FileNotFoundError:
|
|
760
|
+
return {
|
|
761
|
+
"status": "pending",
|
|
762
|
+
"attempts": 0,
|
|
763
|
+
"next_attempt": 0,
|
|
764
|
+
"reason": "buffered",
|
|
765
|
+
}
|
|
766
|
+
except (ValueError, UnicodeError):
|
|
767
|
+
raise DeliveryError("invalid_record") from None
|
|
768
|
+
if (
|
|
769
|
+
not isinstance(state, dict)
|
|
770
|
+
or set(state) != {"status", "attempts", "next_attempt", "reason"}
|
|
771
|
+
or not isinstance(state["status"], str)
|
|
772
|
+
or state["status"] not in {"pending", "blocked"}
|
|
773
|
+
or type(state["attempts"]) is not int
|
|
774
|
+
or state["attempts"] < 0
|
|
775
|
+
or not _number(state["next_attempt"])
|
|
776
|
+
or not isinstance(state["reason"], str)
|
|
777
|
+
or state["reason"] not in _REASONS
|
|
778
|
+
):
|
|
779
|
+
raise DeliveryError("invalid_record")
|
|
780
|
+
return state
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _receipt(queue: _Queue, disposition: Disposition, now: float):
|
|
784
|
+
queue.atomic(
|
|
785
|
+
disposition.delivery_id + ".receipt",
|
|
786
|
+
_encoded({**disposition.as_dict(), "completed_at": now}),
|
|
787
|
+
)
|
|
788
|
+
queue.remove(disposition.delivery_id + ".entry")
|
|
789
|
+
queue.remove(disposition.delivery_id + ".state")
|
|
790
|
+
os.fsync(queue.fd)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _read_receipt(queue: _Queue, name: str) -> dict:
|
|
794
|
+
try:
|
|
795
|
+
receipt = _json(queue.read(name, _MAX_HEADER_BYTES))
|
|
796
|
+
required = {"format_version", "delivery_id", "status", "reason", "completed_at"}
|
|
797
|
+
if not isinstance(receipt, dict) or set(receipt) not in (
|
|
798
|
+
required,
|
|
799
|
+
required | {"fact_id", "stored"},
|
|
800
|
+
):
|
|
801
|
+
raise ValueError
|
|
802
|
+
if (
|
|
803
|
+
type(receipt["format_version"]) is not int
|
|
804
|
+
or receipt["format_version"] != FORMAT_VERSION
|
|
805
|
+
):
|
|
806
|
+
raise ValueError
|
|
807
|
+
if _uuid(receipt["delivery_id"]) + ".receipt" != name or not _number(
|
|
808
|
+
receipt["completed_at"]
|
|
809
|
+
):
|
|
810
|
+
raise ValueError
|
|
811
|
+
status, reason = receipt["status"], receipt["reason"]
|
|
812
|
+
if (
|
|
813
|
+
not isinstance(status, str)
|
|
814
|
+
or status not in _TERMINAL
|
|
815
|
+
or not isinstance(reason, str)
|
|
816
|
+
or reason not in _REASONS
|
|
817
|
+
):
|
|
818
|
+
raise ValueError
|
|
819
|
+
if status == "acknowledged" and reason in {
|
|
820
|
+
"gateway_stored",
|
|
821
|
+
"gateway_duplicate",
|
|
822
|
+
}:
|
|
823
|
+
fact_id = receipt.get("fact_id")
|
|
824
|
+
if (
|
|
825
|
+
not isinstance(fact_id, str)
|
|
826
|
+
or not fact_id
|
|
827
|
+
or fact_id.strip() != fact_id
|
|
828
|
+
or len(fact_id) > 1024
|
|
829
|
+
or any(ord(c) < 32 for c in fact_id)
|
|
830
|
+
or type(receipt.get("stored")) is not bool
|
|
831
|
+
or receipt["stored"] != (reason == "gateway_stored")
|
|
832
|
+
):
|
|
833
|
+
raise ValueError
|
|
834
|
+
elif (
|
|
835
|
+
set(receipt) != required
|
|
836
|
+
or (status == "acknowledged" and reason != "otlp_delivered")
|
|
837
|
+
or (status == "skipped" and reason != "no_session")
|
|
838
|
+
or (
|
|
839
|
+
status == "declined"
|
|
840
|
+
and reason not in {"buffer_full", "expired", "invalid_record"}
|
|
841
|
+
)
|
|
842
|
+
):
|
|
843
|
+
raise ValueError
|
|
844
|
+
return receipt
|
|
845
|
+
except (ValueError, TypeError, KeyError):
|
|
846
|
+
raise DeliveryError("invalid_record") from None
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _maintenance(queue: _Queue, now: float):
|
|
850
|
+
# Validate before any deletion: a malformed receipt cannot erase its payload.
|
|
851
|
+
receipts = {name: _read_receipt(queue, name) for name in queue.names(".receipt")}
|
|
852
|
+
# Enqueue lock excludes unfinished publishers. Only our temporary names go.
|
|
853
|
+
for name in os.listdir(queue.fd):
|
|
854
|
+
if re.fullmatch(r"\.tmp-[0-9a-f-]{36}", name):
|
|
855
|
+
queue.remove(name)
|
|
856
|
+
for name in queue.names(".entry"):
|
|
857
|
+
delivery_id = name.removesuffix(".entry")
|
|
858
|
+
if (
|
|
859
|
+
not re.fullmatch(r"[0-9a-f-]{36}", delivery_id)
|
|
860
|
+
or _uuid(delivery_id) != delivery_id
|
|
861
|
+
):
|
|
862
|
+
raise DeliveryError("invalid_record")
|
|
863
|
+
if delivery_id + ".receipt" in receipts:
|
|
864
|
+
# A crash after receipt publication can leave the acknowledged body.
|
|
865
|
+
queue.remove(name)
|
|
866
|
+
queue.remove(delivery_id + ".state")
|
|
867
|
+
continue
|
|
868
|
+
try:
|
|
869
|
+
header = _header(queue, name)
|
|
870
|
+
if now - header["enqueued_at"] < REPLAY_WINDOW_SECONDS:
|
|
871
|
+
continue
|
|
872
|
+
reason = "expired"
|
|
873
|
+
except DeliveryError as exc:
|
|
874
|
+
if exc.reason != "invalid_record":
|
|
875
|
+
raise
|
|
876
|
+
reason = "invalid_record"
|
|
877
|
+
_receipt(queue, _report(Disposition(delivery_id, "declined", reason)), now)
|
|
878
|
+
retained = []
|
|
879
|
+
for name in queue.names(".receipt"):
|
|
880
|
+
completed = _read_receipt(queue, name)["completed_at"]
|
|
881
|
+
if now - completed >= RECEIPT_RETENTION_SECONDS:
|
|
882
|
+
queue.remove(name)
|
|
883
|
+
else:
|
|
884
|
+
retained.append((completed, name))
|
|
885
|
+
for _, name in sorted(retained)[: max(0, len(retained) - MAX_RECEIPTS)]:
|
|
886
|
+
queue.remove(name)
|
|
887
|
+
os.fsync(queue.fd)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def enqueue(request: DeliveryRequest, directory: str | Path) -> Disposition:
|
|
891
|
+
"""Publish exact bytes and fsync before returning queued; never perform HTTP."""
|
|
892
|
+
delivery_id = str(uuid.uuid4())
|
|
893
|
+
try:
|
|
894
|
+
request = _validated(request)
|
|
895
|
+
delivery_id = request.delivery_id
|
|
896
|
+
with _Queue(directory, create=True) as queue, queue.lock("enqueue.lock"):
|
|
897
|
+
now = time.time()
|
|
898
|
+
_maintenance(queue, now)
|
|
899
|
+
names = queue.names(".entry")
|
|
900
|
+
if delivery_id + ".entry" in names:
|
|
901
|
+
original = _request(queue, _header(queue, delivery_id + ".entry"))
|
|
902
|
+
if original != request:
|
|
903
|
+
return _report(
|
|
904
|
+
Disposition(delivery_id, "declined", "identity_conflict")
|
|
905
|
+
)
|
|
906
|
+
return _report(Disposition(delivery_id, "queued", "already_buffered"))
|
|
907
|
+
if delivery_id + ".receipt" in queue.names(".receipt"):
|
|
908
|
+
return _report(
|
|
909
|
+
Disposition(delivery_id, "declined", "identity_conflict")
|
|
910
|
+
)
|
|
911
|
+
used = sum(_header(queue, name)["bytes"] for name in names)
|
|
912
|
+
if (
|
|
913
|
+
len(names) >= MAX_ACTIVE_ENTRIES
|
|
914
|
+
or used + len(request.body) > MAX_ACTIVE_BYTES
|
|
915
|
+
):
|
|
916
|
+
result = Disposition(delivery_id, "declined", "buffer_full")
|
|
917
|
+
try:
|
|
918
|
+
_receipt(queue, result, now)
|
|
919
|
+
_maintenance(queue, now)
|
|
920
|
+
except (OSError, ValueError) as exc:
|
|
921
|
+
# A failed diagnostic must not erase the capacity decision
|
|
922
|
+
# and make the caller treat this as eligible for fallback.
|
|
923
|
+
logger.warning(
|
|
924
|
+
"delivery_receipt_failed delivery_id=%s reason=%s disposition=%s",
|
|
925
|
+
delivery_id,
|
|
926
|
+
_failure(exc),
|
|
927
|
+
result.reason,
|
|
928
|
+
)
|
|
929
|
+
return _report(result)
|
|
930
|
+
header = {
|
|
931
|
+
"format_version": FORMAT_VERSION,
|
|
932
|
+
"delivery_id": delivery_id,
|
|
933
|
+
"channel": request.channel,
|
|
934
|
+
"destination": request.destination,
|
|
935
|
+
"captured_at": request.captured_at,
|
|
936
|
+
"enqueued_at": now,
|
|
937
|
+
"bytes": len(request.body),
|
|
938
|
+
"sha256": hashlib.sha256(request.body).hexdigest(),
|
|
939
|
+
}
|
|
940
|
+
queue.atomic(
|
|
941
|
+
delivery_id + ".entry", _encoded(header) + b"\n" + request.body
|
|
942
|
+
)
|
|
943
|
+
return _report(Disposition(delivery_id, "queued", "buffered"))
|
|
944
|
+
except (OSError, ValueError) as exc:
|
|
945
|
+
return _report(Disposition(delivery_id, "declined", _failure(exc)))
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def deliver(
|
|
949
|
+
request: DeliveryRequest, *, env: Mapping[str, str] | None = None
|
|
950
|
+
) -> Disposition:
|
|
951
|
+
"""Enqueue when enabled; storage faults degrade to one best-effort attempt."""
|
|
952
|
+
env = os.environ if env is None else env
|
|
953
|
+
directory = env.get("SEDIMENT_DELIVERY_DIR", "").strip()
|
|
954
|
+
if directory:
|
|
955
|
+
result = enqueue(request, directory)
|
|
956
|
+
if result.status != "declined" or result.reason not in {
|
|
957
|
+
"unsafe_storage",
|
|
958
|
+
"storage_unavailable",
|
|
959
|
+
"storage_busy",
|
|
960
|
+
}:
|
|
961
|
+
return result
|
|
962
|
+
logger.warning(
|
|
963
|
+
"delivery_mode delivery_id=%s mode=best_effort reason=%s",
|
|
964
|
+
result.delivery_id,
|
|
965
|
+
result.reason,
|
|
966
|
+
)
|
|
967
|
+
return replace(send_once(request, env=env), fallback_reason=result.reason)
|
|
968
|
+
logger.warning("delivery_mode best_effort reason=buffer_disabled")
|
|
969
|
+
return send_once(request, env=env)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _summary() -> dict:
|
|
973
|
+
return {
|
|
974
|
+
"format_version": FORMAT_VERSION,
|
|
975
|
+
"attempted": 0,
|
|
976
|
+
"acknowledged": 0,
|
|
977
|
+
"skipped": 0,
|
|
978
|
+
"pending": 0,
|
|
979
|
+
"blocked": 0,
|
|
980
|
+
"worker_busy": False,
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _batch(
|
|
985
|
+
queue: _Queue,
|
|
986
|
+
env: Mapping[str, str],
|
|
987
|
+
*,
|
|
988
|
+
retry_blocked: bool,
|
|
989
|
+
stop_event: threading.Event | None = None,
|
|
990
|
+
) -> dict:
|
|
991
|
+
summary = _summary()
|
|
992
|
+
with queue.lock("enqueue.lock"):
|
|
993
|
+
_maintenance(queue, time.time())
|
|
994
|
+
candidates = []
|
|
995
|
+
for name in queue.names(".entry"):
|
|
996
|
+
header = _header(queue, name)
|
|
997
|
+
delivery_id = header["delivery_id"]
|
|
998
|
+
try:
|
|
999
|
+
due = (
|
|
1000
|
+
_state(queue, delivery_id)["next_attempt"] or header["enqueued_at"]
|
|
1001
|
+
)
|
|
1002
|
+
except DeliveryError as exc:
|
|
1003
|
+
if exc.reason != "invalid_record":
|
|
1004
|
+
raise
|
|
1005
|
+
_receipt(
|
|
1006
|
+
queue,
|
|
1007
|
+
_report(Disposition(delivery_id, "declined", "invalid_record")),
|
|
1008
|
+
time.time(),
|
|
1009
|
+
)
|
|
1010
|
+
continue
|
|
1011
|
+
candidates.append((due, header))
|
|
1012
|
+
# Schedule by due time, including enqueue time for untouched entries.
|
|
1013
|
+
# Slow failures cannot reclaim every slot, nor can new arrivals starve retries.
|
|
1014
|
+
headers = [
|
|
1015
|
+
header
|
|
1016
|
+
for _, header in sorted(
|
|
1017
|
+
candidates,
|
|
1018
|
+
key=lambda pair: (
|
|
1019
|
+
pair[0],
|
|
1020
|
+
pair[1]["enqueued_at"],
|
|
1021
|
+
pair[1]["delivery_id"],
|
|
1022
|
+
),
|
|
1023
|
+
)
|
|
1024
|
+
]
|
|
1025
|
+
for header in headers:
|
|
1026
|
+
if summary["attempted"] >= MAX_BATCH_ATTEMPTS or (
|
|
1027
|
+
stop_event is not None and stop_event.is_set()
|
|
1028
|
+
):
|
|
1029
|
+
break
|
|
1030
|
+
delivery_id = header["delivery_id"]
|
|
1031
|
+
with queue.lock("enqueue.lock"):
|
|
1032
|
+
if delivery_id + ".entry" not in queue.names(".entry"):
|
|
1033
|
+
continue
|
|
1034
|
+
if time.time() - header["enqueued_at"] >= REPLAY_WINDOW_SECONDS:
|
|
1035
|
+
_receipt(
|
|
1036
|
+
queue,
|
|
1037
|
+
_report(Disposition(delivery_id, "declined", "expired")),
|
|
1038
|
+
time.time(),
|
|
1039
|
+
)
|
|
1040
|
+
continue
|
|
1041
|
+
try:
|
|
1042
|
+
state = _state(queue, delivery_id)
|
|
1043
|
+
if state["status"] == "blocked" and not retry_blocked:
|
|
1044
|
+
continue
|
|
1045
|
+
if state["status"] != "blocked" and state["next_attempt"] > time.time():
|
|
1046
|
+
continue
|
|
1047
|
+
request = _request(queue, header)
|
|
1048
|
+
except DeliveryError as exc:
|
|
1049
|
+
if exc.reason != "invalid_record":
|
|
1050
|
+
raise
|
|
1051
|
+
_receipt(
|
|
1052
|
+
queue,
|
|
1053
|
+
_report(Disposition(delivery_id, "declined", "invalid_record")),
|
|
1054
|
+
time.time(),
|
|
1055
|
+
)
|
|
1056
|
+
continue
|
|
1057
|
+
try:
|
|
1058
|
+
_configuration(request, env)
|
|
1059
|
+
except DeliveryError as exc:
|
|
1060
|
+
result = _report(Disposition(delivery_id, "blocked", exc.reason))
|
|
1061
|
+
else:
|
|
1062
|
+
result = send_once(request, env=env)
|
|
1063
|
+
summary["attempted"] += 1
|
|
1064
|
+
with queue.lock("enqueue.lock"):
|
|
1065
|
+
now = time.time()
|
|
1066
|
+
# An enqueue can expire this entry while HTTP runs outside the lock.
|
|
1067
|
+
# Its terminal receipt wins; do not resurrect a deleted transport copy.
|
|
1068
|
+
if delivery_id + ".entry" not in queue.names(".entry"):
|
|
1069
|
+
continue
|
|
1070
|
+
if result.status in _TERMINAL:
|
|
1071
|
+
_receipt(queue, result, now)
|
|
1072
|
+
else:
|
|
1073
|
+
attempts = state["attempts"] + 1
|
|
1074
|
+
ceiling = min(MAX_BACKOFF_SECONDS, 2 ** min(attempts, 6))
|
|
1075
|
+
delay = ceiling / 2 + secrets.randbelow(1001) / 1000 * ceiling / 2
|
|
1076
|
+
queue.atomic(
|
|
1077
|
+
delivery_id + ".state",
|
|
1078
|
+
_encoded(
|
|
1079
|
+
{
|
|
1080
|
+
"status": result.status,
|
|
1081
|
+
"attempts": attempts,
|
|
1082
|
+
"next_attempt": now + delay,
|
|
1083
|
+
"reason": result.reason,
|
|
1084
|
+
}
|
|
1085
|
+
),
|
|
1086
|
+
)
|
|
1087
|
+
summary[result.status] += 1
|
|
1088
|
+
with queue.lock("enqueue.lock"):
|
|
1089
|
+
_maintenance(queue, time.time())
|
|
1090
|
+
return summary
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def replay(
|
|
1094
|
+
directory: str | Path,
|
|
1095
|
+
*,
|
|
1096
|
+
env: Mapping[str, str] | None = None,
|
|
1097
|
+
retry_blocked: bool = False,
|
|
1098
|
+
) -> dict:
|
|
1099
|
+
"""Drain up to 32 due entries once each; a failed entry never monopolizes work."""
|
|
1100
|
+
env = os.environ if env is None else env
|
|
1101
|
+
with _Queue(directory, create=False) as queue:
|
|
1102
|
+
try:
|
|
1103
|
+
with queue.lock("worker.lock", wait=False):
|
|
1104
|
+
return _batch(queue, env, retry_blocked=retry_blocked)
|
|
1105
|
+
except DeliveryError as exc:
|
|
1106
|
+
if exc.reason != "worker_busy":
|
|
1107
|
+
raise
|
|
1108
|
+
return {**_summary(), "worker_busy": True}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def watch(
|
|
1112
|
+
directory: str | Path,
|
|
1113
|
+
*,
|
|
1114
|
+
env: Mapping[str, str] | None = None,
|
|
1115
|
+
stop_event: threading.Event | None = None,
|
|
1116
|
+
retry_blocked: bool = False,
|
|
1117
|
+
) -> dict:
|
|
1118
|
+
"""Own one worker until stopped, finishing at most its in-flight HTTP attempt.
|
|
1119
|
+
|
|
1120
|
+
An explicitly enrolled worker initializes its private directory and locks
|
|
1121
|
+
before the first enqueue. Status never initializes storage.
|
|
1122
|
+
"""
|
|
1123
|
+
env = os.environ if env is None else env
|
|
1124
|
+
stopped = threading.Event() if stop_event is None else stop_event
|
|
1125
|
+
result = _summary()
|
|
1126
|
+
while not stopped.is_set():
|
|
1127
|
+
try:
|
|
1128
|
+
queue = _Queue(directory, create=True)
|
|
1129
|
+
except (OSError, ValueError) as exc:
|
|
1130
|
+
logger.warning("delivery_worker reason=%s", _failure(exc))
|
|
1131
|
+
return {**result, "error": _failure(exc)}
|
|
1132
|
+
with queue:
|
|
1133
|
+
try:
|
|
1134
|
+
with queue.lock("worker.lock", wait=False):
|
|
1135
|
+
while not stopped.is_set():
|
|
1136
|
+
try:
|
|
1137
|
+
result = _batch(
|
|
1138
|
+
queue,
|
|
1139
|
+
env,
|
|
1140
|
+
retry_blocked=retry_blocked,
|
|
1141
|
+
stop_event=stopped,
|
|
1142
|
+
)
|
|
1143
|
+
retry_blocked = False
|
|
1144
|
+
except (OSError, ValueError) as exc:
|
|
1145
|
+
logger.warning("delivery_worker reason=%s", _failure(exc))
|
|
1146
|
+
result = {**_summary(), "error": _failure(exc)}
|
|
1147
|
+
stopped.wait(1)
|
|
1148
|
+
except (OSError, ValueError) as exc:
|
|
1149
|
+
if isinstance(exc, DeliveryError) and exc.reason == "worker_busy":
|
|
1150
|
+
return {**result, "worker_busy": True}
|
|
1151
|
+
logger.warning("delivery_worker reason=%s", _failure(exc))
|
|
1152
|
+
return {**result, "error": _failure(exc)}
|
|
1153
|
+
return result
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def status(directory: str | Path) -> dict:
|
|
1157
|
+
"""Inspect without creating directories, records, or worker enrollment."""
|
|
1158
|
+
result = {
|
|
1159
|
+
"format_version": FORMAT_VERSION,
|
|
1160
|
+
"pending": 0,
|
|
1161
|
+
"pending_bytes": 0,
|
|
1162
|
+
"oldest_pending_age_seconds": None,
|
|
1163
|
+
"blocked": 0,
|
|
1164
|
+
"blocked_bytes": 0,
|
|
1165
|
+
"active_bytes": 0,
|
|
1166
|
+
"receipts": {},
|
|
1167
|
+
"worker_running": False,
|
|
1168
|
+
}
|
|
1169
|
+
try:
|
|
1170
|
+
queue = _Queue(directory, create=False)
|
|
1171
|
+
except FileNotFoundError:
|
|
1172
|
+
return result
|
|
1173
|
+
with queue:
|
|
1174
|
+
try:
|
|
1175
|
+
with queue.lock("worker.lock", wait=False, create=False):
|
|
1176
|
+
pass
|
|
1177
|
+
except FileNotFoundError:
|
|
1178
|
+
pass
|
|
1179
|
+
except DeliveryError as exc:
|
|
1180
|
+
if exc.reason != "worker_busy":
|
|
1181
|
+
raise
|
|
1182
|
+
result["worker_running"] = True
|
|
1183
|
+
if "enqueue.lock" not in os.listdir(queue.fd):
|
|
1184
|
+
if (
|
|
1185
|
+
queue.names(".entry")
|
|
1186
|
+
or queue.names(".receipt")
|
|
1187
|
+
or queue.names(".state")
|
|
1188
|
+
):
|
|
1189
|
+
raise DeliveryError("invalid_record")
|
|
1190
|
+
return result
|
|
1191
|
+
with queue.lock("enqueue.lock", create=False):
|
|
1192
|
+
now, ages = time.time(), []
|
|
1193
|
+
receipt_names = {
|
|
1194
|
+
name: _read_receipt(queue, name) for name in queue.names(".receipt")
|
|
1195
|
+
}
|
|
1196
|
+
for name in queue.names(".entry"):
|
|
1197
|
+
header = _header(queue, name)
|
|
1198
|
+
if header["delivery_id"] + ".receipt" in receipt_names:
|
|
1199
|
+
continue
|
|
1200
|
+
state = _state(queue, header["delivery_id"])
|
|
1201
|
+
category = "blocked" if state["status"] == "blocked" else "pending"
|
|
1202
|
+
result[category] += 1
|
|
1203
|
+
result[category + "_bytes"] += header["bytes"]
|
|
1204
|
+
result["active_bytes"] += header["bytes"]
|
|
1205
|
+
if category == "pending":
|
|
1206
|
+
ages.append(max(0, now - header["enqueued_at"]))
|
|
1207
|
+
result["oldest_pending_age_seconds"] = max(ages) if ages else None
|
|
1208
|
+
counts = Counter(receipt["reason"] for receipt in receipt_names.values())
|
|
1209
|
+
result["receipts"] = dict(sorted(counts.items()))
|
|
1210
|
+
return result
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def build_parser(*, prog: str | None = None) -> argparse.ArgumentParser:
|
|
1214
|
+
parser = argparse.ArgumentParser(
|
|
1215
|
+
prog=prog,
|
|
1216
|
+
description="Deliver prepared capture payloads; OTLP acknowledgment is not a Fact count.",
|
|
1217
|
+
)
|
|
1218
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1219
|
+
for command, help_text in (
|
|
1220
|
+
("enqueue", "durably enqueue a bounded JSON request from stdin"),
|
|
1221
|
+
("status", "inspect private buffer and worker state"),
|
|
1222
|
+
("replay", "replay a bounded batch of due payloads"),
|
|
1223
|
+
):
|
|
1224
|
+
child = sub.add_parser(command, help=help_text)
|
|
1225
|
+
child.add_argument(
|
|
1226
|
+
"--directory",
|
|
1227
|
+
default=None,
|
|
1228
|
+
help="private buffer (default: SEDIMENT_DELIVERY_DIR)",
|
|
1229
|
+
)
|
|
1230
|
+
if command == "enqueue":
|
|
1231
|
+
child.add_argument(
|
|
1232
|
+
"--fallback-direct",
|
|
1233
|
+
action="store_true",
|
|
1234
|
+
help="allow one best-effort send when buffering is disabled or storage fails",
|
|
1235
|
+
)
|
|
1236
|
+
if command == "replay":
|
|
1237
|
+
child.add_argument(
|
|
1238
|
+
"--watch", action="store_true", help="supervise replay until stopped"
|
|
1239
|
+
)
|
|
1240
|
+
child.add_argument(
|
|
1241
|
+
"--retry-blocked",
|
|
1242
|
+
action="store_true",
|
|
1243
|
+
help="retry blocked payloads once; stop the existing worker first",
|
|
1244
|
+
)
|
|
1245
|
+
return parser
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def request_from_dict(value: dict) -> DeliveryRequest:
|
|
1249
|
+
if (
|
|
1250
|
+
not isinstance(value, dict)
|
|
1251
|
+
or not {"channel", "destination", "body_base64"} <= set(value)
|
|
1252
|
+
or set(value)
|
|
1253
|
+
- {"channel", "destination", "body_base64", "captured_at", "delivery_id"}
|
|
1254
|
+
):
|
|
1255
|
+
raise DeliveryError("invalid_request")
|
|
1256
|
+
if (
|
|
1257
|
+
not isinstance(value["body_base64"], str)
|
|
1258
|
+
or len(value["body_base64"]) > ((MAX_ENTRY_BYTES + 2) // 3) * 4
|
|
1259
|
+
):
|
|
1260
|
+
raise DeliveryError("entry_too_large")
|
|
1261
|
+
try:
|
|
1262
|
+
body = base64.b64decode(value["body_base64"], validate=True)
|
|
1263
|
+
except (ValueError, binascii.Error):
|
|
1264
|
+
raise DeliveryError("invalid_request") from None
|
|
1265
|
+
return prepare_request(
|
|
1266
|
+
value["channel"],
|
|
1267
|
+
value["destination"],
|
|
1268
|
+
body,
|
|
1269
|
+
captured_at=value.get("captured_at"),
|
|
1270
|
+
delivery_id=value.get("delivery_id"),
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def main(argv: list[str] | None = None, *, prog: str | None = None) -> int:
|
|
1275
|
+
args = build_parser(prog=prog).parse_args(argv)
|
|
1276
|
+
directory = (
|
|
1277
|
+
args.directory
|
|
1278
|
+
if args.directory is not None
|
|
1279
|
+
else os.environ.get("SEDIMENT_DELIVERY_DIR", "")
|
|
1280
|
+
).strip()
|
|
1281
|
+
fallback = args.command == "enqueue" and args.fallback_direct
|
|
1282
|
+
try:
|
|
1283
|
+
if not directory and not fallback:
|
|
1284
|
+
raise DeliveryError("buffer_disabled")
|
|
1285
|
+
if args.command == "enqueue":
|
|
1286
|
+
data = sys.stdin.buffer.read(MAX_STDIN_BYTES + 1)
|
|
1287
|
+
if len(data) > MAX_STDIN_BYTES:
|
|
1288
|
+
raise DeliveryError("entry_too_large")
|
|
1289
|
+
try:
|
|
1290
|
+
request = request_from_dict(_json(data))
|
|
1291
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
1292
|
+
raise DeliveryError(
|
|
1293
|
+
exc.reason if isinstance(exc, DeliveryError) else "invalid_request"
|
|
1294
|
+
) from None
|
|
1295
|
+
result = (
|
|
1296
|
+
deliver(request, env={**os.environ, "SEDIMENT_DELIVERY_DIR": directory})
|
|
1297
|
+
if fallback
|
|
1298
|
+
else enqueue(request, directory)
|
|
1299
|
+
)
|
|
1300
|
+
print(json.dumps(result.as_dict(), sort_keys=True))
|
|
1301
|
+
return (
|
|
1302
|
+
0
|
|
1303
|
+
if result.status == "queued"
|
|
1304
|
+
or (fallback and result.status in {"acknowledged", "skipped"})
|
|
1305
|
+
else 1
|
|
1306
|
+
)
|
|
1307
|
+
if args.command == "status":
|
|
1308
|
+
print(json.dumps(status(directory), sort_keys=True))
|
|
1309
|
+
return 0
|
|
1310
|
+
stopped = threading.Event()
|
|
1311
|
+
prior = {}
|
|
1312
|
+
if args.watch:
|
|
1313
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
1314
|
+
prior[signum] = signal.signal(signum, lambda *_: stopped.set())
|
|
1315
|
+
try:
|
|
1316
|
+
result = (
|
|
1317
|
+
watch(directory, stop_event=stopped, retry_blocked=args.retry_blocked)
|
|
1318
|
+
if args.watch
|
|
1319
|
+
else replay(directory, retry_blocked=args.retry_blocked)
|
|
1320
|
+
)
|
|
1321
|
+
print(json.dumps(result, sort_keys=True), flush=True)
|
|
1322
|
+
return 1 if "error" in result else 0
|
|
1323
|
+
finally:
|
|
1324
|
+
for signum, handler in prior.items():
|
|
1325
|
+
signal.signal(signum, handler)
|
|
1326
|
+
except (OSError, ValueError) as exc:
|
|
1327
|
+
reason = _failure(exc)
|
|
1328
|
+
result = _report(Disposition(str(uuid.uuid4()), "declined", reason))
|
|
1329
|
+
print(json.dumps(result.as_dict(), sort_keys=True))
|
|
1330
|
+
return 1
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
if __name__ == "__main__":
|
|
1334
|
+
sys.exit(main())
|