memd-engine 0.5.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.
- memd/__init__.py +7 -0
- memd/cli.py +699 -0
- memd/core/__init__.py +31 -0
- memd/core/schema.py +343 -0
- memd/engine/__init__.py +0 -0
- memd/engine/forward.py +1175 -0
- memd/engine/memory.py +3005 -0
- memd/harness/core.py +257 -0
- memd/harness/run.py +216 -0
- memd/harness/suites/__init__.py +0 -0
- memd/harness/suites/adversarial.py +113 -0
- memd/harness/suites/halumem_ops.py +54 -0
- memd/harness/suites/longmemeval_synthetic.py +221 -0
- memd/hosted/__init__.py +20 -0
- memd/hosted/app.py +258 -0
- memd/hosted/billing.py +909 -0
- memd/hosted/metering.py +460 -0
- memd/hosted/plans.py +138 -0
- memd/hosted/store.py +830 -0
- memd/index/__init__.py +3 -0
- memd/index/ann_usearch.py +1598 -0
- memd/index/sqlite_index.py +2413 -0
- memd/index/tantivy_lexical.py +886 -0
- memd/metrics.py +591 -0
- memd/pipeline/__init__.py +0 -0
- memd/pipeline/consolidation.py +174 -0
- memd/pipeline/embedder.py +370 -0
- memd/pipeline/extractor.py +567 -0
- memd/query/__init__.py +0 -0
- memd/query/dates.py +212 -0
- memd/query/fusion.py +90 -0
- memd/query/packing.py +671 -0
- memd/query/planner.py +76 -0
- memd/query/rerank.py +532 -0
- memd/sdk/__init__.py +3 -0
- memd/sdk/client.py +292 -0
- memd/server/__init__.py +0 -0
- memd/server/auth.py +423 -0
- memd/server/cluster.py +748 -0
- memd/server/http.py +976 -0
- memd/server/mcp_server.py +147 -0
- memd/storage/__init__.py +3 -0
- memd/storage/audit.py +504 -0
- memd/storage/crypto.py +1313 -0
- memd/storage/engine.py +5331 -0
- memd/storage/objectstore.py +597 -0
- memd/storage/replica.py +1040 -0
- memd/storage/s3store.py +1450 -0
- memd_engine-0.5.0.dist-info/METADATA +1230 -0
- memd_engine-0.5.0.dist-info/RECORD +54 -0
- memd_engine-0.5.0.dist-info/WHEEL +5 -0
- memd_engine-0.5.0.dist-info/entry_points.txt +2 -0
- memd_engine-0.5.0.dist-info/licenses/LICENSE +202 -0
- memd_engine-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""LongMemEval-class synthetic suite.
|
|
2
|
+
|
|
3
|
+
Deterministic generator (seeded): sessions of facts per user, then query
|
|
4
|
+
classes mirroring LongMemEval's: single-hop, multi-hop, temporal, and the
|
|
5
|
+
wedge classes - knowledge-update, knowledge-overwrite, abstention.
|
|
6
|
+
|
|
7
|
+
The real LongMemEval is used when MEMD_LME_DATA points at a JSON file with
|
|
8
|
+
either {"events": [...], "cases": [...]} in this suite's schema, or a list of
|
|
9
|
+
rows. Two row shapes are accepted:
|
|
10
|
+
- the real LongMemEval release (longmemeval_s / _m / _oracle):
|
|
11
|
+
{"question_id", "question_type", "question", "question_date", "answer",
|
|
12
|
+
"answer_session_ids", "haystack_dates", "haystack_session_ids",
|
|
13
|
+
"haystack_sessions": [[{"role", "content", "has_answer"?}, ...], ...]}
|
|
14
|
+
- a flat legacy shape:
|
|
15
|
+
{"session": [{"content": ..., "role": ..., "user_id"?...}],
|
|
16
|
+
"question": ..., "answer": ...}
|
|
17
|
+
Rows are converted to events+cases at load time. The synthetic suite exists
|
|
18
|
+
so CI can gate every commit offline with zero network.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import random
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
DAY = 86_400_000
|
|
29
|
+
T0 = 1_700_000_000_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def generate_cases(seed: int = 42, users: int = 8) -> tuple[list[dict], list[dict]]:
|
|
33
|
+
rng = random.Random(seed)
|
|
34
|
+
names = ["Alice", "Bob", "Cara", "Dan", "Elena", "Frank", "Grace", "Hank"]
|
|
35
|
+
cities = ["Lisbon", "Oslo", "Kyoto", "Austin", "Nairobi", "Perth"]
|
|
36
|
+
tools = ["make ship", "cargo deploy", "npm run release", "just prod", "fab rollout"]
|
|
37
|
+
editors = ["Neovim", "Emacs", "VS Code", "Zed"]
|
|
38
|
+
|
|
39
|
+
events: list[dict] = []
|
|
40
|
+
cases: list[dict] = []
|
|
41
|
+
|
|
42
|
+
for u in range(users):
|
|
43
|
+
uid = f"u{u}"
|
|
44
|
+
# pools cycle so --users beyond len(names) works (the CLI exposes
|
|
45
|
+
# users as free-form; an IndexError here bricked the whole harness).
|
|
46
|
+
# Uniqueness comes from uid-scoped session ids and per-user queries.
|
|
47
|
+
name, city, tool, editor = (
|
|
48
|
+
names[u % len(names)], cities[u % len(cities)],
|
|
49
|
+
tools[u % len(tools)], editors[u % len(editors)],
|
|
50
|
+
)
|
|
51
|
+
base = T0 + u * DAY
|
|
52
|
+
|
|
53
|
+
def ev(day: int, content: str):
|
|
54
|
+
events.append({"content": content, "user_id": uid, "session_id": f"s{uid}-{day}",
|
|
55
|
+
"role": "user", "t_event": base + day * DAY})
|
|
56
|
+
|
|
57
|
+
# stable facts
|
|
58
|
+
ev(0, f"My name is {name}.")
|
|
59
|
+
ev(0, f"I live in {city}.")
|
|
60
|
+
ev(1, f"We deploy with `{tool}`, never raw ftp.")
|
|
61
|
+
ev(2, f"I use {editor} as my editor.")
|
|
62
|
+
# temporal facts
|
|
63
|
+
ev(3, f"I moved to {city} in 2021.")
|
|
64
|
+
ev(4, f"My review meeting is scheduled on March 3rd.")
|
|
65
|
+
|
|
66
|
+
# single-hop
|
|
67
|
+
cases.append({
|
|
68
|
+
"id": f"{uid}-sh-deploy", "qclass": "single_hop", "user_id": uid,
|
|
69
|
+
"query": f"How do we deploy?",
|
|
70
|
+
"expected": tool,
|
|
71
|
+
"events_hint": None,
|
|
72
|
+
})
|
|
73
|
+
cases.append({
|
|
74
|
+
"id": f"{uid}-sh-editor", "qclass": "single_hop", "user_id": uid,
|
|
75
|
+
"query": f"What editor does {name} use?",
|
|
76
|
+
"expected": editor,
|
|
77
|
+
})
|
|
78
|
+
# multi-hop: name -> city via two facts
|
|
79
|
+
cases.append({
|
|
80
|
+
"id": f"{uid}-mh-city", "qclass": "multi_hop", "user_id": uid,
|
|
81
|
+
"query": f"Where does {name} live?",
|
|
82
|
+
"expected": city,
|
|
83
|
+
})
|
|
84
|
+
# temporal
|
|
85
|
+
cases.append({
|
|
86
|
+
"id": f"{uid}-tp-review", "qclass": "temporal", "user_id": uid,
|
|
87
|
+
"query": "When is my review meeting?",
|
|
88
|
+
"expected": "March 3rd",
|
|
89
|
+
})
|
|
90
|
+
# knowledge-update: old employer then new one; only latest correct
|
|
91
|
+
old_job, new_job = "Initech", "Initrode"
|
|
92
|
+
ev(10, f"I just started working at {old_job}.")
|
|
93
|
+
ev(40, f"I switched jobs, now I work at {new_job}.")
|
|
94
|
+
cases.append({
|
|
95
|
+
"id": f"{uid}-ku-employer", "qclass": "knowledge_update", "user_id": uid,
|
|
96
|
+
"query": f"Where does {name} work?",
|
|
97
|
+
"expected": new_job,
|
|
98
|
+
"stale": old_job,
|
|
99
|
+
})
|
|
100
|
+
return_value = f"ticket #{100+u}"
|
|
101
|
+
ev(20, f"My support ticket got resolved, reference {return_value}.")
|
|
102
|
+
cases.append({
|
|
103
|
+
"id": f"{uid}-sh-ticket", "qclass": "single_hop", "user_id": uid,
|
|
104
|
+
"query": "What was my support ticket reference?",
|
|
105
|
+
"expected": return_value,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
# distractor volume: makes the full-context baseline pay real token
|
|
109
|
+
# cost and retrieval work under noise (LongMemEval-style long horizon)
|
|
110
|
+
topics = ["standup", "refactor", "bug bash", "on-call", "retro", "planning",
|
|
111
|
+
"lint", "flaky test", "cache", "auth flow", "docs", "telemetry"]
|
|
112
|
+
for d in range(400):
|
|
113
|
+
t = topics[d % len(topics)]
|
|
114
|
+
ev(5 + d % 90, f"Notes from {t} session number {d}: we discussed the {t} "
|
|
115
|
+
f"and agreed to follow up about {t} details later.")
|
|
116
|
+
_ = rng # reserved for future noise injection
|
|
117
|
+
return events, cases
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_answer(ans: str, case: dict) -> tuple[bool, float]:
|
|
121
|
+
expected = str(case["expected"]).lower()
|
|
122
|
+
a = ans.lower()
|
|
123
|
+
hit = expected in a
|
|
124
|
+
stale_leak = False
|
|
125
|
+
if case.get("stale"):
|
|
126
|
+
stale_leak = str(case["stale"]).lower() in a and not hit
|
|
127
|
+
if not hit or stale_leak:
|
|
128
|
+
return False, 0.0
|
|
129
|
+
return True, 1.0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build(seed: int = 42, users: int = 8) -> tuple[list[dict], list[dict]]:
|
|
133
|
+
path = os.environ.get("MEMD_LME_DATA")
|
|
134
|
+
if path:
|
|
135
|
+
return load_real(path)
|
|
136
|
+
return generate_cases(seed=seed, users=users)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_LME_DATE = re.compile(r"\s*(\d{4})/(\d{1,2})/(\d{1,2})(?:.*?(\d{1,2}):(\d{2}))?")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _lme_date_ms(s: str) -> int | None:
|
|
143
|
+
"""'2023/05/20 (Sat) 02:21' -> epoch ms (UTC; the dataset has no zone)."""
|
|
144
|
+
m = _LME_DATE.match(str(s))
|
|
145
|
+
if not m:
|
|
146
|
+
return None
|
|
147
|
+
y, mo, d, hh, mm = (int(x) if x else 0 for x in m.groups())
|
|
148
|
+
dt = _dt.datetime(y, mo, d, hh, mm, tzinfo=_dt.timezone.utc)
|
|
149
|
+
return int(dt.timestamp() * 1000)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load_lme_row(row: dict, events: list[dict], cases: list[dict]) -> None:
|
|
153
|
+
"""One real LongMemEval question: its own user (haystacks are per
|
|
154
|
+
question), one event per haystack turn, one case. Abstention variants
|
|
155
|
+
(question_id ending in `_abs`) are skipped: substring grading of
|
|
156
|
+
"expected" cannot score an abstention."""
|
|
157
|
+
qid = str(row["question_id"])
|
|
158
|
+
if qid.endswith("_abs"):
|
|
159
|
+
return
|
|
160
|
+
uid = qid
|
|
161
|
+
sids = row.get("haystack_session_ids") or []
|
|
162
|
+
dates = row.get("haystack_dates") or []
|
|
163
|
+
for k, session in enumerate(row.get("haystack_sessions") or []):
|
|
164
|
+
sid = str(sids[k]) if k < len(sids) else f"s{k}"
|
|
165
|
+
t_event = _lme_date_ms(dates[k]) if k < len(dates) else None
|
|
166
|
+
for turn in session:
|
|
167
|
+
content = turn.get("content") if isinstance(turn, dict) else turn
|
|
168
|
+
if not content:
|
|
169
|
+
continue
|
|
170
|
+
events.append({
|
|
171
|
+
"content": str(content), "user_id": uid,
|
|
172
|
+
# uid-scoped: the same distractor session id recurs across
|
|
173
|
+
# questions, and the harness closes sessions by id alone
|
|
174
|
+
"session_id": f"{uid}:{sid}",
|
|
175
|
+
"role": (turn.get("role") if isinstance(turn, dict) else None) or "user",
|
|
176
|
+
"t_event": t_event,
|
|
177
|
+
})
|
|
178
|
+
cases.append({
|
|
179
|
+
"id": qid, "qclass": row.get("question_type", "real"), "user_id": uid,
|
|
180
|
+
"query": str(row["question"]), "expected": str(row["answer"]),
|
|
181
|
+
"question_date": row.get("question_date"),
|
|
182
|
+
"answer_session_ids": list(row.get("answer_session_ids") or []),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load_real(path: str) -> tuple[list[dict], list[dict]]:
|
|
187
|
+
"""Load a LongMemEval-derived dataset and normalize it into
|
|
188
|
+
(events, cases) with the same schema as the synthetic generator."""
|
|
189
|
+
with open(path) as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
if isinstance(data, dict) and "events" in data and "cases" in data:
|
|
192
|
+
return data["events"], data["cases"]
|
|
193
|
+
rows = data if isinstance(data, list) else []
|
|
194
|
+
events: list[dict] = []
|
|
195
|
+
cases: list[dict] = []
|
|
196
|
+
for i, row in enumerate(rows):
|
|
197
|
+
if isinstance(row, dict) and "haystack_sessions" in row and "question_id" in row:
|
|
198
|
+
_load_lme_row(row, events, cases)
|
|
199
|
+
continue
|
|
200
|
+
uid = f"lme{i}"
|
|
201
|
+
sess_events = row.get("session") or row.get("haystack") or row.get("context") or []
|
|
202
|
+
for j, s in enumerate(sess_events):
|
|
203
|
+
content = s.get("content") if isinstance(s, dict) else str(s)
|
|
204
|
+
if not content:
|
|
205
|
+
continue
|
|
206
|
+
events.append({
|
|
207
|
+
"content": str(content), "user_id": uid,
|
|
208
|
+
"session_id": s.get("session_id", f"{uid}-s{j//20}") if isinstance(s, dict) else f"{uid}-s{j//20}",
|
|
209
|
+
"role": (s.get("role", "user") if isinstance(s, dict) else "user"),
|
|
210
|
+
})
|
|
211
|
+
q = row.get("question") or row.get("query")
|
|
212
|
+
a = row.get("answer") or row.get("expected")
|
|
213
|
+
if q and a:
|
|
214
|
+
cases.append({
|
|
215
|
+
"id": f"lme{i}", "qclass": row.get("qclass", row.get("category", "real")),
|
|
216
|
+
"user_id": uid, "query": str(q), "expected": str(a),
|
|
217
|
+
"stale": row.get("stale"),
|
|
218
|
+
})
|
|
219
|
+
if not cases:
|
|
220
|
+
raise ValueError(f"{path}: no usable LongMemEval rows found")
|
|
221
|
+
return events, cases
|
memd/hosted/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Hosted mode: tenancy (org -> namespaces -> API keys), usage metering,
|
|
2
|
+
entitlements and Stripe billing.
|
|
3
|
+
|
|
4
|
+
OFF by default. Only `memd serve --http --hosted` (or MEMD_HOSTED=1) loads
|
|
5
|
+
this package; embedded and self-hosted memd never import it, and nothing here
|
|
6
|
+
imports `stripe` at module level (it is the optional `memd[billing]` extra,
|
|
7
|
+
imported lazily by memd.hosted.billing when a Stripe key is configured).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hosted_enabled(flag: bool | None = None) -> bool:
|
|
17
|
+
"""An explicit flag wins; otherwise MEMD_HOSTED decides (default off)."""
|
|
18
|
+
if flag is not None:
|
|
19
|
+
return bool(flag)
|
|
20
|
+
return os.environ.get("MEMD_HOSTED", "").strip().lower() in _TRUE
|
memd/hosted/app.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Hosted-mode wiring for the REST server: the org-aware key store, the
|
|
2
|
+
metering hooks, the /v1/billing routes and the background billing jobs.
|
|
3
|
+
|
|
4
|
+
memd.server.http imports this module only when hosted mode is on."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hmac
|
|
8
|
+
import importlib.util
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import Depends, Request
|
|
17
|
+
from fastapi.concurrency import run_in_threadpool
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from memd.hosted.billing import Billing, BillingConfig, BillingError
|
|
21
|
+
from memd.hosted.metering import Forbidden, Metering, QuotaDenied
|
|
22
|
+
from memd.hosted.plans import Plans
|
|
23
|
+
from memd.hosted.store import AdminStore, parse_scopes, period_bounds, period_of
|
|
24
|
+
from memd.metrics import METRICS
|
|
25
|
+
from memd.server.auth import Principal, hash_secret
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("memd.hosted")
|
|
28
|
+
_OPS = {"ns": "_billing"}
|
|
29
|
+
# Stripe's events are a few KiB; nothing legitimate comes near this
|
|
30
|
+
WEBHOOK_MAX_BYTES = 1 << 20
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheckoutIn(BaseModel):
|
|
34
|
+
plan: str = Field(min_length=1, max_length=64)
|
|
35
|
+
interval: str = "month"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HostedKeyStore:
|
|
39
|
+
"""KeyStore-compatible adapter over the admin store: keys belong to an
|
|
40
|
+
org and are bound to one of its namespaces; only SHA-256 hashes of the
|
|
41
|
+
secrets are stored. The operator key (MEMD_ADMIN_KEY) stays org-less:
|
|
42
|
+
it is never metered or billed. Legacy keys.toml.json keys are NOT
|
|
43
|
+
honoured in hosted mode (they carry no org, so they would bypass
|
|
44
|
+
metering) - adopt them with `memd key migrate --org ...`."""
|
|
45
|
+
|
|
46
|
+
CACHE_TTL_S = 5.0 # bounded staleness for a revocation made by another process
|
|
47
|
+
CACHE_MAX = 10_000
|
|
48
|
+
|
|
49
|
+
def __init__(self, store: AdminStore, admin_key: str | None = None):
|
|
50
|
+
self.store = store
|
|
51
|
+
self._admin_hash = hash_secret(admin_key) if admin_key else None
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._cache: "OrderedDict[str, tuple[Principal, float]]" = OrderedDict()
|
|
54
|
+
|
|
55
|
+
def authenticate(self, bearer: str | None) -> Principal | None:
|
|
56
|
+
if not bearer:
|
|
57
|
+
return None
|
|
58
|
+
h = hash_secret(bearer)
|
|
59
|
+
if self._admin_hash and hmac.compare_digest(h, self._admin_hash):
|
|
60
|
+
return Principal(key_id="admin", namespace="*", scope_override=True)
|
|
61
|
+
now = time.monotonic()
|
|
62
|
+
with self._lock:
|
|
63
|
+
hit = self._cache.get(h)
|
|
64
|
+
if hit is not None and now - hit[1] < self.CACHE_TTL_S:
|
|
65
|
+
return hit[0]
|
|
66
|
+
rec = self.store.key_for_bearer(bearer)
|
|
67
|
+
if rec is None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._cache.pop(h, None)
|
|
70
|
+
return None
|
|
71
|
+
scopes = frozenset(rec["scopes"].split())
|
|
72
|
+
p = Principal(key_id=rec["key_id"], namespace=rec["ns"], pinned_user=rec.get("pinned_user"),
|
|
73
|
+
scope_override="override" in scopes, org_id=rec["org_id"], scopes=scopes)
|
|
74
|
+
with self._lock:
|
|
75
|
+
self._cache[h] = (p, now)
|
|
76
|
+
self._cache.move_to_end(h)
|
|
77
|
+
while len(self._cache) > self.CACHE_MAX:
|
|
78
|
+
self._cache.popitem(last=False)
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
def create(self, namespace: str, name: str = "", pinned_user: str | None = None,
|
|
82
|
+
scope_override: bool = False, *, org_id: str, scopes: str | list[str] | None = None
|
|
83
|
+
) -> tuple[str, str]:
|
|
84
|
+
sc = parse_scopes(scopes)
|
|
85
|
+
if scope_override and "override" not in sc:
|
|
86
|
+
sc.append("override")
|
|
87
|
+
return self.store.create_key(org_id, namespace, name=name, scopes=sc, pinned_user=pinned_user)
|
|
88
|
+
|
|
89
|
+
def revoke(self, kid: str) -> bool:
|
|
90
|
+
ok = self.store.revoke_key(kid)
|
|
91
|
+
with self._lock:
|
|
92
|
+
for h, (p, _) in list(self._cache.items()):
|
|
93
|
+
if p.key_id == kid:
|
|
94
|
+
self._cache.pop(h, None)
|
|
95
|
+
return ok
|
|
96
|
+
|
|
97
|
+
def list_keys(self) -> list[dict]:
|
|
98
|
+
return self.store.list_keys()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BillingJobs:
|
|
102
|
+
"""Background loop: the daily gauge snapshot, the hourly meter push, the
|
|
103
|
+
settling of usage too old to push on its own, and the daily drift
|
|
104
|
+
report. Every step is idempotent, so a restart mid-step is safe."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, hosted: "Hosted"):
|
|
107
|
+
self.hosted = hosted
|
|
108
|
+
self.push_interval_s = float(os.environ.get("MEMD_BILLING_PUSH_INTERVAL_S", "3600"))
|
|
109
|
+
self._stop = threading.Event()
|
|
110
|
+
self._thread: threading.Thread | None = None
|
|
111
|
+
self._snapshot_day: str | None = None
|
|
112
|
+
self._reconciled_day: int | None = None
|
|
113
|
+
|
|
114
|
+
def run_once(self, now: float | None = None) -> dict:
|
|
115
|
+
h = self.hosted
|
|
116
|
+
now = time.time() if now is None else now
|
|
117
|
+
out: dict[str, Any] = {}
|
|
118
|
+
if h.router is not None:
|
|
119
|
+
# cluster: every node records the size of the namespaces IT holds
|
|
120
|
+
# (only the leaseholder can read one without taking it over), and
|
|
121
|
+
# one node - the rendezvous choice for "_billing" among the live
|
|
122
|
+
# nodes - runs the singleton steps below
|
|
123
|
+
out["measured"] = h.metering.measure_open()
|
|
124
|
+
if not h.router.is_leader("_billing"):
|
|
125
|
+
out["leader"] = False
|
|
126
|
+
return out
|
|
127
|
+
day = time.strftime("%Y-%m-%d", time.gmtime(now))
|
|
128
|
+
if day != self._snapshot_day:
|
|
129
|
+
out["snapshot"] = h.metering.snapshot_gauges(now=now)
|
|
130
|
+
self._snapshot_day = day
|
|
131
|
+
if h.billing.cfg.configured:
|
|
132
|
+
out["push"] = h.billing.push_usage(now=now)
|
|
133
|
+
# every tick: usage too old to push on its own is settled against
|
|
134
|
+
# what Stripe verifiably holds (a no-op when there is none)
|
|
135
|
+
out["reconcile_pending"] = h.billing.reconcile_pending(now=now)
|
|
136
|
+
today = int(now // 86400) * 86400
|
|
137
|
+
if self._reconciled_day != today:
|
|
138
|
+
# the drift report covers yesterday's quota period to date
|
|
139
|
+
out["reconcile"] = h.billing.reconcile(period_bounds(period_of(today - 1))[0], today)
|
|
140
|
+
self._reconciled_day = today
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def _loop(self) -> None:
|
|
144
|
+
while not self._stop.is_set():
|
|
145
|
+
try:
|
|
146
|
+
self.run_once()
|
|
147
|
+
except Exception:
|
|
148
|
+
METRICS.inc("memd_billing_job_failures_total", **_OPS)
|
|
149
|
+
_log.exception("memd billing: background job failed")
|
|
150
|
+
self._stop.wait(self.push_interval_s)
|
|
151
|
+
|
|
152
|
+
def start(self) -> None:
|
|
153
|
+
if self._thread is None and os.environ.get("MEMD_BILLING_JOBS", "1") != "0":
|
|
154
|
+
self._thread = threading.Thread(target=self._loop, daemon=True, name="memd-billing-jobs")
|
|
155
|
+
self._thread.start()
|
|
156
|
+
|
|
157
|
+
def stop(self) -> None:
|
|
158
|
+
self._stop.set()
|
|
159
|
+
if self._thread is not None:
|
|
160
|
+
self._thread.join(timeout=10)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Hosted:
|
|
164
|
+
def __init__(self, data_dir: str, engine: Any, *, admin_key: str | None = None,
|
|
165
|
+
plans: Plans | None = None, billing_config: BillingConfig | None = None,
|
|
166
|
+
stripe_client: Any = None, router: Any = None):
|
|
167
|
+
# from_env runs the sk_live_ guard: a refused key stops startup here
|
|
168
|
+
self.cfg = billing_config or BillingConfig.from_env()
|
|
169
|
+
if (self.cfg.configured or self.cfg.webhook_secret) and importlib.util.find_spec("stripe") is None:
|
|
170
|
+
raise RuntimeError("hosted billing is configured but the Stripe SDK is missing: "
|
|
171
|
+
"pip install 'memd-engine[billing]'")
|
|
172
|
+
self.plans = plans or Plans.from_env()
|
|
173
|
+
self.store = AdminStore.for_data_root(data_dir)
|
|
174
|
+
# the cluster router (memd.server.cluster.Router) when this process
|
|
175
|
+
# is one node of a fleet sharing this admin database, else None
|
|
176
|
+
self.router = router
|
|
177
|
+
if router is None:
|
|
178
|
+
# one process per data root: reservations on disk are a dead process's
|
|
179
|
+
self.store.clear_reservations()
|
|
180
|
+
# (a cluster shares the database: the other nodes' reservations are
|
|
181
|
+
# live, and a dead node's expire by their TTL)
|
|
182
|
+
self.keystore = HostedKeyStore(self.store, admin_key=admin_key)
|
|
183
|
+
self.metering = Metering(self.store, self.plans, engine)
|
|
184
|
+
self.billing = Billing(self.store, self.plans, self.cfg, client=stripe_client)
|
|
185
|
+
self.jobs = BillingJobs(self)
|
|
186
|
+
if not self.cfg.configured:
|
|
187
|
+
_log.warning("memd hosted: no MEMD_STRIPE_SECRET_KEY - tenancy and quotas are on, "
|
|
188
|
+
"billing endpoints answer 503 billing_not_configured")
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
self.jobs.stop()
|
|
192
|
+
self.store.close()
|
|
193
|
+
|
|
194
|
+
def install(self, app: Any, auth: Any) -> None:
|
|
195
|
+
"""Register the /v1/billing routes and the 402/403 handlers."""
|
|
196
|
+
from memd.server.http import ApiError, _error
|
|
197
|
+
|
|
198
|
+
hosted = self
|
|
199
|
+
|
|
200
|
+
@app.exception_handler(QuotaDenied)
|
|
201
|
+
async def quota_denied(_request: Request, exc: QuotaDenied):
|
|
202
|
+
return _error(402, exc.detail, exc.code, extra=exc.extra)
|
|
203
|
+
|
|
204
|
+
@app.exception_handler(Forbidden)
|
|
205
|
+
async def forbidden(_request: Request, exc: Forbidden):
|
|
206
|
+
return _error(403, exc.detail)
|
|
207
|
+
|
|
208
|
+
@app.exception_handler(BillingError)
|
|
209
|
+
async def billing_error(_request: Request, exc: BillingError):
|
|
210
|
+
return _error(exc.status, exc.detail, exc.code, extra=exc.extra)
|
|
211
|
+
|
|
212
|
+
def billing_org(p: Principal = Depends(auth)) -> dict:
|
|
213
|
+
if p.org_id is None:
|
|
214
|
+
raise ApiError(400, "billing routes act on the calling key's org; this key has none",
|
|
215
|
+
code="no_org")
|
|
216
|
+
if "billing" not in p.scopes: # exact: `override` does not imply it
|
|
217
|
+
raise ApiError(403, "key lacks the 'billing' scope")
|
|
218
|
+
org = hosted.store.get_org(p.org_id)
|
|
219
|
+
if org is None:
|
|
220
|
+
raise ApiError(404, "org not found")
|
|
221
|
+
return org
|
|
222
|
+
|
|
223
|
+
@app.post("/v1/billing/checkout")
|
|
224
|
+
def checkout(body: CheckoutIn, org: dict = Depends(billing_org)):
|
|
225
|
+
return hosted.billing.checkout(org, body.plan, body.interval)
|
|
226
|
+
|
|
227
|
+
@app.post("/v1/billing/portal")
|
|
228
|
+
def portal(org: dict = Depends(billing_org)):
|
|
229
|
+
return hosted.billing.portal(org)
|
|
230
|
+
|
|
231
|
+
@app.get("/v1/billing/usage")
|
|
232
|
+
def usage(org: dict = Depends(billing_org)):
|
|
233
|
+
return hosted.metering.usage_report(org["id"])
|
|
234
|
+
|
|
235
|
+
@app.post("/v1/billing/webhook")
|
|
236
|
+
async def webhook(request: Request):
|
|
237
|
+
# unauthenticated by design: the Stripe-Signature HMAC over the
|
|
238
|
+
# RAW body is the authentication (parsed JSON would not verify).
|
|
239
|
+
# Capped by bytes actually READ: a chunked body has no
|
|
240
|
+
# Content-Length for the global 8 MiB guard to see.
|
|
241
|
+
buf = bytearray()
|
|
242
|
+
async for chunk in request.stream():
|
|
243
|
+
buf += chunk
|
|
244
|
+
if len(buf) > WEBHOOK_MAX_BYTES:
|
|
245
|
+
METRICS.inc("memd_billing_webhook_rejected_total", reason="too_large", **_OPS)
|
|
246
|
+
return _error(413, "webhook body too large")
|
|
247
|
+
event = hosted.billing.verify_webhook(bytes(buf), request.headers.get("stripe-signature"))
|
|
248
|
+
try:
|
|
249
|
+
result = await run_in_threadpool(hosted.billing.handle_event, event)
|
|
250
|
+
except BillingError:
|
|
251
|
+
raise
|
|
252
|
+
except Exception:
|
|
253
|
+
_log.exception("memd billing: webhook handler failed for %s", event.get("id"))
|
|
254
|
+
METRICS.inc("memd_billing_webhook_total", type=str(event.get("type")), result="error", **_OPS)
|
|
255
|
+
return _error(500, "webhook handler failed") # Stripe re-delivers
|
|
256
|
+
if result.get("retry"):
|
|
257
|
+
return _error(500, "event not applicable yet; retry", "retry", extra={"result": result})
|
|
258
|
+
return {"received": True, **result}
|