xp-libs-analytics 0.4.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.
- xp_libs_analytics/__init__.py +18 -0
- xp_libs_analytics/engine/__init__.py +1 -0
- xp_libs_analytics/engine/backend.py +379 -0
- xp_libs_analytics/engine/planner.py +450 -0
- xp_libs_analytics/engine/worker.py +121 -0
- xp_libs_analytics/errors/__init__.py +43 -0
- xp_libs_analytics/errors/catalog.json +75 -0
- xp_libs_analytics/errors/codes.py +81 -0
- xp_libs_analytics/ingest/__init__.py +309 -0
- xp_libs_analytics/interfaces.py +70 -0
- xp_libs_analytics/matching/__init__.py +1 -0
- xp_libs_analytics/matching/compiler.py +344 -0
- xp_libs_analytics/matching/diagnostics.py +115 -0
- xp_libs_analytics/matching/models.py +98 -0
- xp_libs_analytics/matching/normalize.py +16 -0
- xp_libs_analytics/matching/patterns.py +71 -0
- xp_libs_analytics/mcp/__init__.py +1 -0
- xp_libs_analytics/mcp/server.py +248 -0
- xp_libs_analytics/models/__init__.py +654 -0
- xp_libs_analytics/parser/__init__.py +142 -0
- xp_libs_analytics/policy/__init__.py +69 -0
- xp_libs_analytics/py.typed +0 -0
- xp_libs_analytics/registry.py +204 -0
- xp_libs_analytics/sdk/__init__.py +259 -0
- xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
- xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
- xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
- xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
- xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
- xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
- xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +175 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Type resolution and conservative admission before expression compilation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from xp_libs_analytics.errors import AnalyticsError
|
|
14
|
+
from xp_libs_analytics.ingest import Snapshot, flatten, kind, pointer_tokens
|
|
15
|
+
from xp_libs_analytics.matching.models import TEXT_OPS as MATCH_OPS
|
|
16
|
+
from xp_libs_analytics.matching.models import MatchFieldsPredicate, is_matching
|
|
17
|
+
from xp_libs_analytics.models import AnalyticsQuery, HavingPredicate, Predicate, RelativeTime
|
|
18
|
+
from xp_libs_analytics.policy import Limits, Principal
|
|
19
|
+
|
|
20
|
+
INTERVALS = {
|
|
21
|
+
"1s": 1,
|
|
22
|
+
"5s": 5,
|
|
23
|
+
"10s": 10,
|
|
24
|
+
"30s": 30,
|
|
25
|
+
"1m": 60,
|
|
26
|
+
"5m": 300,
|
|
27
|
+
"15m": 900,
|
|
28
|
+
"30m": 1800,
|
|
29
|
+
"1h": 3600,
|
|
30
|
+
}
|
|
31
|
+
NUMERIC_OPS = set("sum avg median p50 p90 p95 p99 stddev variance sum_if avg_if".split())
|
|
32
|
+
NULL_OPS = {"is_null", "is_not_null", "is_missing", "is_present"}
|
|
33
|
+
ORDER_OPS = {"gt", "gte", "lt", "lte", "between"}
|
|
34
|
+
TEXT_OPS = {"contains", "starts_with", "ends_with"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def timestamp(value: Any) -> datetime:
|
|
38
|
+
if type(value) is not str or not re.fullmatch(
|
|
39
|
+
r"\d{4}-\d\d-\d\d[Tt]\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:[Zz]|[+-]\d\d:\d\d)", value
|
|
40
|
+
):
|
|
41
|
+
raise ValueError
|
|
42
|
+
return datetime.fromisoformat(value.upper().replace("Z", "+00:00")).astimezone(UTC)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cast_value(value: Any, target: str, fmt: str | None) -> Any:
|
|
46
|
+
if type(value) in (bool, dict, list):
|
|
47
|
+
if target == "string" and type(value) is bool:
|
|
48
|
+
return "true" if value else "false"
|
|
49
|
+
raise ValueError
|
|
50
|
+
if target == "string":
|
|
51
|
+
return str(value)
|
|
52
|
+
if target == "datetime":
|
|
53
|
+
if fmt == "rfc3339":
|
|
54
|
+
return timestamp(value)
|
|
55
|
+
if type(value) not in (int, float):
|
|
56
|
+
raise ValueError
|
|
57
|
+
divisor = {"epoch_s": 1, "epoch_ms": 1000, "epoch_us": 1_000_000}[str(fmt)]
|
|
58
|
+
return datetime.fromtimestamp(value / divisor, UTC)
|
|
59
|
+
if target == "float64":
|
|
60
|
+
if type(value) is str and re.fullmatch(r"[+-]?[0-9]+", value) and abs(int(value)) > 2**53:
|
|
61
|
+
raise ValueError
|
|
62
|
+
converted = float(value)
|
|
63
|
+
if not math.isfinite(converted) or type(value) is int and abs(value) > 2**53:
|
|
64
|
+
raise ValueError
|
|
65
|
+
return converted
|
|
66
|
+
converted = int(value)
|
|
67
|
+
if not -(2**63) <= converted < 2**63 or type(value) is float and converted != value:
|
|
68
|
+
raise ValueError
|
|
69
|
+
return converted
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def walk(predicate: Predicate | HavingPredicate) -> list[Predicate | HavingPredicate]:
|
|
73
|
+
if predicate.op in ("and", "or"):
|
|
74
|
+
return [predicate] + [n for c in getattr(predicate, "conditions") for n in walk(c)]
|
|
75
|
+
if predicate.op == "not":
|
|
76
|
+
return [predicate] + walk(getattr(predicate, "condition"))
|
|
77
|
+
return [predicate]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def predicate_depth(predicate: Predicate | HavingPredicate) -> int:
|
|
81
|
+
if predicate.op in ("and", "or"):
|
|
82
|
+
return 1 + max(predicate_depth(c) for c in getattr(predicate, "conditions"))
|
|
83
|
+
if predicate.op == "not":
|
|
84
|
+
return 1 + predicate_depth(getattr(predicate, "condition"))
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_predicate(
|
|
89
|
+
predicate: Predicate | HavingPredicate,
|
|
90
|
+
types: dict[str, str],
|
|
91
|
+
*,
|
|
92
|
+
having: bool = False,
|
|
93
|
+
limits: Limits | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
for node in walk(predicate):
|
|
96
|
+
if node.op in ("and", "or", "not"):
|
|
97
|
+
continue
|
|
98
|
+
if not having and (
|
|
99
|
+
node.op == "match"
|
|
100
|
+
or node.op in MATCH_OPS - TEXT_OPS
|
|
101
|
+
or getattr(node, "normalization", None) is not None
|
|
102
|
+
):
|
|
103
|
+
from xp_libs_analytics.matching.compiler import validate_rules
|
|
104
|
+
|
|
105
|
+
validate_rules([node], types, limits or Limits())
|
|
106
|
+
continue
|
|
107
|
+
if getattr(node, "field") not in types:
|
|
108
|
+
raise AnalyticsError("QUERY_HAVING_INVALID" if having else "DATA_FIELD_UNKNOWN")
|
|
109
|
+
field_type = types[getattr(node, "field")]
|
|
110
|
+
if node.op in NULL_OPS:
|
|
111
|
+
continue
|
|
112
|
+
if field_type == "mixed":
|
|
113
|
+
raise AnalyticsError("DATA_FIELD_TYPE_CONFLICT")
|
|
114
|
+
if field_type in ("object", "array"):
|
|
115
|
+
raise AnalyticsError("DATA_FIELD_NOT_SCALAR")
|
|
116
|
+
if node.op in TEXT_OPS and field_type not in ("string", "null"):
|
|
117
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
118
|
+
if (
|
|
119
|
+
node.op in TEXT_OPS
|
|
120
|
+
and getattr(node, "value") is not None
|
|
121
|
+
and type(getattr(node, "value")) is not str
|
|
122
|
+
):
|
|
123
|
+
raise AnalyticsError("QUERY_INVALID_LITERAL")
|
|
124
|
+
if node.op in ORDER_OPS and field_type == "boolean":
|
|
125
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
126
|
+
values = (
|
|
127
|
+
getattr(node, "values")
|
|
128
|
+
if node.op in ("in", "not_in")
|
|
129
|
+
else [getattr(node, "lower"), getattr(node, "upper")]
|
|
130
|
+
if node.op == "between"
|
|
131
|
+
else [getattr(node, "value")]
|
|
132
|
+
)
|
|
133
|
+
literal_types = {kind(v) for v in values if v is not None}
|
|
134
|
+
if len(literal_types) > 1 and not literal_types <= {"integer", "float"}:
|
|
135
|
+
raise AnalyticsError("QUERY_INVALID_LITERAL")
|
|
136
|
+
for value in values:
|
|
137
|
+
if value is None or field_type == "null":
|
|
138
|
+
continue
|
|
139
|
+
compatible = kind(value) == field_type or {kind(value), field_type} <= {
|
|
140
|
+
"integer",
|
|
141
|
+
"float",
|
|
142
|
+
}
|
|
143
|
+
if field_type == "datetime":
|
|
144
|
+
try:
|
|
145
|
+
timestamp(value)
|
|
146
|
+
compatible = True
|
|
147
|
+
except ValueError:
|
|
148
|
+
compatible = False
|
|
149
|
+
if not compatible:
|
|
150
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
151
|
+
if node.op == "between":
|
|
152
|
+
lower, upper = getattr(node, "lower"), getattr(node, "upper")
|
|
153
|
+
if lower is None or upper is None:
|
|
154
|
+
raise AnalyticsError("QUERY_INVALID_LITERAL")
|
|
155
|
+
if field_type == "datetime":
|
|
156
|
+
lower, upper = timestamp(lower), timestamp(upper)
|
|
157
|
+
if lower > upper:
|
|
158
|
+
raise AnalyticsError("QUERY_INVALID_LITERAL")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class LogicalPlan:
|
|
163
|
+
version: str
|
|
164
|
+
query_json: str
|
|
165
|
+
fingerprint: str
|
|
166
|
+
fields: tuple[str, ...]
|
|
167
|
+
types: tuple[tuple[str, str], ...]
|
|
168
|
+
columns: tuple[tuple[str, tuple[Any, ...]], ...]
|
|
169
|
+
quality_json: str
|
|
170
|
+
start: datetime | None
|
|
171
|
+
end: datetime | None
|
|
172
|
+
window_size: int
|
|
173
|
+
window_step: int
|
|
174
|
+
estimated_groups: int
|
|
175
|
+
estimated_windows: int
|
|
176
|
+
input_rows: int
|
|
177
|
+
matching_cost_json: str = "{}"
|
|
178
|
+
diagnostic_key: bytes = b""
|
|
179
|
+
|
|
180
|
+
def explain(self) -> dict[str, Any]:
|
|
181
|
+
return {
|
|
182
|
+
"version": self.version,
|
|
183
|
+
"fingerprint": self.fingerprint,
|
|
184
|
+
"stages": [
|
|
185
|
+
"scan",
|
|
186
|
+
"project",
|
|
187
|
+
"filter",
|
|
188
|
+
"window",
|
|
189
|
+
"aggregate",
|
|
190
|
+
"having",
|
|
191
|
+
"sort",
|
|
192
|
+
"limit",
|
|
193
|
+
"encode",
|
|
194
|
+
],
|
|
195
|
+
"matching": json.loads(self.matching_cost_json),
|
|
196
|
+
"cost": {
|
|
197
|
+
"input_rows": self.input_rows,
|
|
198
|
+
"groups": self.estimated_groups,
|
|
199
|
+
"windows": self.estimated_windows,
|
|
200
|
+
"expanded_rows": self.input_rows
|
|
201
|
+
* max(1, self.window_size // max(1, self.window_step)),
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def plan_query(
|
|
207
|
+
query: AnalyticsQuery, snapshot: Snapshot, principal: Principal, limits: Limits, now: datetime
|
|
208
|
+
) -> LogicalPlan:
|
|
209
|
+
if "read" not in principal.scopes:
|
|
210
|
+
raise AnalyticsError("ACCESS_DENIED")
|
|
211
|
+
schema = snapshot.schema()
|
|
212
|
+
predicates = list(query.filters) + [a.where for a in query.aggregations if hasattr(a, "where")]
|
|
213
|
+
nodes = [n for p in predicates for n in walk(p)]
|
|
214
|
+
expanded_work = sum(
|
|
215
|
+
len(n.match.fields) * len(n.match.patterns) if isinstance(n, MatchFieldsPredicate) else 1
|
|
216
|
+
for n in nodes
|
|
217
|
+
)
|
|
218
|
+
if expanded_work > 32 and any(isinstance(n, MatchFieldsPredicate) for n in nodes):
|
|
219
|
+
raise AnalyticsError("MATCH_COMPLEXITY_BUDGET_EXCEEDED")
|
|
220
|
+
if len(nodes) > 32 or any(predicate_depth(p) > 4 for p in predicates):
|
|
221
|
+
raise AnalyticsError("QUERY_PREDICATE_LIMIT")
|
|
222
|
+
if sum(len(walk(p)) for p in query.having) > 10 or any(
|
|
223
|
+
predicate_depth(p) > 4 for p in query.having
|
|
224
|
+
):
|
|
225
|
+
raise AnalyticsError("QUERY_PREDICATE_LIMIT")
|
|
226
|
+
fields = {n.field for n in nodes if hasattr(n, "field")}
|
|
227
|
+
fields.update(
|
|
228
|
+
field for n in nodes if isinstance(n, MatchFieldsPredicate) for field in n.match.fields
|
|
229
|
+
)
|
|
230
|
+
fields.update(a.field for a in query.aggregations if hasattr(a, "field"))
|
|
231
|
+
fields.update(g.field for g in query.group_by)
|
|
232
|
+
fields.update(c.field for c in query.casts)
|
|
233
|
+
if query.time_range:
|
|
234
|
+
fields.add(query.time_range.field)
|
|
235
|
+
for field in sorted(fields):
|
|
236
|
+
pointer_tokens(field, limits.max_depth)
|
|
237
|
+
# Hidden and nonexistent fields are indistinguishable for restricted principals.
|
|
238
|
+
if not principal.permits(field) or principal.fields is not None and field not in schema:
|
|
239
|
+
raise AnalyticsError("ACCESS_FIELD_DENIED")
|
|
240
|
+
if field not in schema:
|
|
241
|
+
raise AnalyticsError("DATA_FIELD_UNKNOWN")
|
|
242
|
+
details = query.options.matching_details
|
|
243
|
+
if details.include and details.top_values > limits.max_match_diagnostic_values:
|
|
244
|
+
raise AnalyticsError("MATCH_COMPLEXITY_BUDGET_EXCEEDED")
|
|
245
|
+
matching_types = {p: schema[p]["type"] for p in fields}
|
|
246
|
+
matching_types.update(
|
|
247
|
+
(c.field, {"int64": "integer", "float64": "float"}.get(c.to, c.to)) for c in query.casts
|
|
248
|
+
)
|
|
249
|
+
if details.include_raw_values:
|
|
250
|
+
match_fields = {
|
|
251
|
+
field
|
|
252
|
+
for n in nodes
|
|
253
|
+
if is_matching(n, matching_types)
|
|
254
|
+
for field in (
|
|
255
|
+
n.match.fields if isinstance(n, MatchFieldsPredicate) else [getattr(n, "field")]
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
if not details.include:
|
|
259
|
+
raise AnalyticsError("QUERY_MALFORMED")
|
|
260
|
+
if not limits.allow_match_raw_values or not match_fields <= principal.match_raw_fields:
|
|
261
|
+
raise AnalyticsError("MATCH_DIAGNOSTICS_UNAUTHORIZED")
|
|
262
|
+
# Check matching syntax against authorized post-cast types before reading records.
|
|
263
|
+
has_matching = any(is_matching(n, matching_types) for n in nodes)
|
|
264
|
+
if has_matching:
|
|
265
|
+
from xp_libs_analytics.matching.compiler import validate_rules
|
|
266
|
+
|
|
267
|
+
validate_rules(nodes, matching_types, limits)
|
|
268
|
+
aliases = [g.alias for g in query.group_by] + [a.alias for a in query.aggregations]
|
|
269
|
+
reserved = {"window_start", "window_end", "query_id", "group_key_status"}
|
|
270
|
+
if len(set(aliases)) != len(aliases) or any(
|
|
271
|
+
not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", a) or a in reserved for a in aliases
|
|
272
|
+
):
|
|
273
|
+
raise AnalyticsError("QUERY_ALIAS_INVALID")
|
|
274
|
+
if len({g.field for g in query.group_by}) != len(query.group_by):
|
|
275
|
+
raise AnalyticsError("QUERY_GROUP_INVALID")
|
|
276
|
+
if len({c.field for c in query.casts}) != len(query.casts):
|
|
277
|
+
raise AnalyticsError("DATA_CAST_UNSUPPORTED")
|
|
278
|
+
records = [flatten(r) for r in snapshot.records()]
|
|
279
|
+
types = {p: schema[p]["type"] for p in fields}
|
|
280
|
+
columns: dict[str, tuple[Any, ...]] = {}
|
|
281
|
+
quality = {}
|
|
282
|
+
cast_map = {c.field: c for c in query.casts}
|
|
283
|
+
for i, p in enumerate(sorted(fields)):
|
|
284
|
+
present = tuple(p in r for r in records)
|
|
285
|
+
values = [r.get(p) for r in records]
|
|
286
|
+
errors = 0
|
|
287
|
+
cast = cast_map.get(p)
|
|
288
|
+
if cast:
|
|
289
|
+
if (cast.to == "datetime") != (cast.format is not None):
|
|
290
|
+
raise AnalyticsError("DATA_CAST_UNSUPPORTED")
|
|
291
|
+
if cast.on_error == "null" and not limits.allow_null_cast:
|
|
292
|
+
raise AnalyticsError("ACCESS_DENIED")
|
|
293
|
+
if types[p] in ("object", "array"):
|
|
294
|
+
raise AnalyticsError("DATA_FIELD_NOT_SCALAR")
|
|
295
|
+
for j, value in enumerate(values):
|
|
296
|
+
if value is not None:
|
|
297
|
+
try:
|
|
298
|
+
values[j] = cast_value(value, cast.to, cast.format)
|
|
299
|
+
except (ValueError, TypeError, OverflowError, OSError):
|
|
300
|
+
if cast.on_error == "reject":
|
|
301
|
+
raise AnalyticsError("DATA_CAST_FAILED") from None
|
|
302
|
+
values[j] = None
|
|
303
|
+
errors += 1
|
|
304
|
+
types[p] = {"int64": "integer", "float64": "float"}.get(cast.to, cast.to)
|
|
305
|
+
if types[p] == "mixed" and p not in cast_map:
|
|
306
|
+
# Presence/null diagnostics are allowed on mixed fields, typed use is not.
|
|
307
|
+
typed_use = (
|
|
308
|
+
any(g.field == p for g in query.group_by)
|
|
309
|
+
or any(
|
|
310
|
+
getattr(a, "field", None) == p and a.op not in {"missing_count", "null_count"}
|
|
311
|
+
for a in query.aggregations
|
|
312
|
+
)
|
|
313
|
+
or any(getattr(n, "field", None) == p and n.op not in NULL_OPS for n in nodes)
|
|
314
|
+
)
|
|
315
|
+
if typed_use:
|
|
316
|
+
raise AnalyticsError("DATA_FIELD_TYPE_CONFLICT")
|
|
317
|
+
# Complex and mixed values only need presence and null flags for diagnostics.
|
|
318
|
+
stored = tuple(
|
|
319
|
+
None if isinstance(v, (dict, list)) or types[p] == "mixed" else v for v in values
|
|
320
|
+
)
|
|
321
|
+
columns[f"v{i}"] = stored
|
|
322
|
+
columns[f"p{i}"] = present
|
|
323
|
+
columns[f"n{i}"] = tuple(
|
|
324
|
+
exists and value is None for exists, value in zip(present, values, strict=True)
|
|
325
|
+
)
|
|
326
|
+
quality[p] = {
|
|
327
|
+
"present": sum(present),
|
|
328
|
+
"missing": len(values) - sum(present),
|
|
329
|
+
"null": sum(columns[f"n{i}"]),
|
|
330
|
+
"valid": sum(v is not None for v in values),
|
|
331
|
+
"cast_errors": errors,
|
|
332
|
+
}
|
|
333
|
+
for predicate in predicates:
|
|
334
|
+
check_predicate(predicate, types, limits=limits)
|
|
335
|
+
for a in query.aggregations:
|
|
336
|
+
if not hasattr(a, "field"):
|
|
337
|
+
continue
|
|
338
|
+
t = types[a.field]
|
|
339
|
+
if a.op not in ("missing_count", "null_count") and t in ("object", "array"):
|
|
340
|
+
raise AnalyticsError("DATA_FIELD_NOT_SCALAR")
|
|
341
|
+
if a.op in NUMERIC_OPS and t not in ("integer", "float", "null"):
|
|
342
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
343
|
+
if a.op in NUMERIC_OPS - {"sum", "sum_if"} and t == "integer":
|
|
344
|
+
samples = columns[f"v{sorted(fields).index(a.field)}"]
|
|
345
|
+
if any(v is not None and abs(v) > 2**53 for v in samples):
|
|
346
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
347
|
+
if a.op in ("min", "max") and t == "boolean":
|
|
348
|
+
raise AnalyticsError("QUERY_OPERATOR_TYPE")
|
|
349
|
+
if a.op == "count_distinct":
|
|
350
|
+
distinct_values = columns[f"v{sorted(fields).index(a.field)}"]
|
|
351
|
+
if len(set(distinct_values) - {None}) > limits.max_distinct:
|
|
352
|
+
raise AnalyticsError("QUERY_TOO_EXPENSIVE")
|
|
353
|
+
group_keys = []
|
|
354
|
+
for g in query.group_by:
|
|
355
|
+
if types[g.field] in ("object", "array"):
|
|
356
|
+
raise AnalyticsError("QUERY_GROUP_INVALID")
|
|
357
|
+
i = sorted(fields).index(g.field)
|
|
358
|
+
group_keys.append(list(zip(columns[f"v{i}"], columns[f"p{i}"], strict=True)))
|
|
359
|
+
groups = len(set(zip(*group_keys, strict=True))) if group_keys else 1
|
|
360
|
+
if groups > limits.max_groups:
|
|
361
|
+
raise AnalyticsError("RESOURCE_GROUPS_LIMIT")
|
|
362
|
+
start = end = None
|
|
363
|
+
if query.time_range:
|
|
364
|
+
if types[query.time_range.field] != "datetime":
|
|
365
|
+
raise AnalyticsError("DATA_TIMESTAMP_INVALID")
|
|
366
|
+
try:
|
|
367
|
+
if isinstance(query.time_range, RelativeTime):
|
|
368
|
+
end = now.astimezone(UTC)
|
|
369
|
+
start = end - timedelta(minutes=query.time_range.last_minutes)
|
|
370
|
+
else:
|
|
371
|
+
start, end = timestamp(query.time_range.start), timestamp(query.time_range.end)
|
|
372
|
+
if start >= end or end - start > timedelta(days=1):
|
|
373
|
+
raise ValueError
|
|
374
|
+
except ValueError:
|
|
375
|
+
raise AnalyticsError("QUERY_TIME_RANGE_INVALID") from None
|
|
376
|
+
size = step = windows = 0
|
|
377
|
+
if query.window:
|
|
378
|
+
size = INTERVALS.get(query.window.size, 0)
|
|
379
|
+
step = INTERVALS.get(getattr(query.window, "step", query.window.size), 0)
|
|
380
|
+
if (
|
|
381
|
+
start is None
|
|
382
|
+
or end is None
|
|
383
|
+
or not size
|
|
384
|
+
or not step
|
|
385
|
+
or step > size
|
|
386
|
+
or size % step
|
|
387
|
+
or size // step > 12
|
|
388
|
+
):
|
|
389
|
+
raise AnalyticsError("QUERY_WINDOW_INVALID")
|
|
390
|
+
windows = math.ceil((end - start).total_seconds() / step) + size // step
|
|
391
|
+
if windows > limits.max_windows:
|
|
392
|
+
raise AnalyticsError("RESOURCE_WINDOWS_LIMIT")
|
|
393
|
+
if (
|
|
394
|
+
groups * windows > limits.max_groups
|
|
395
|
+
or snapshot.row_count * (size // step) > limits.max_expanded_rows
|
|
396
|
+
):
|
|
397
|
+
raise AnalyticsError("QUERY_TOO_EXPENSIVE")
|
|
398
|
+
if query.options.include_empty_windows and (not query.window or query.group_by):
|
|
399
|
+
raise AnalyticsError("QUERY_EMPTY_WINDOW_UNSUPPORTED")
|
|
400
|
+
result_types = {g.alias: types[g.field] for g in query.group_by}
|
|
401
|
+
for a in query.aggregations:
|
|
402
|
+
result_types[a.alias] = (
|
|
403
|
+
"integer"
|
|
404
|
+
if a.op
|
|
405
|
+
in {
|
|
406
|
+
"count",
|
|
407
|
+
"count_non_null",
|
|
408
|
+
"null_count",
|
|
409
|
+
"missing_count",
|
|
410
|
+
"count_distinct",
|
|
411
|
+
"count_if",
|
|
412
|
+
}
|
|
413
|
+
else types[a.field]
|
|
414
|
+
if a.op in ("min", "max", "sum", "sum_if")
|
|
415
|
+
else "float"
|
|
416
|
+
)
|
|
417
|
+
if query.window:
|
|
418
|
+
result_types.update(window_start="datetime", window_end="datetime")
|
|
419
|
+
for h in query.having:
|
|
420
|
+
check_predicate(h, result_types, having=True)
|
|
421
|
+
if any(o.field not in result_types for o in query.order_by):
|
|
422
|
+
raise AnalyticsError("QUERY_ORDER_INVALID")
|
|
423
|
+
encoded = query.model_dump_json(by_alias=True, exclude_unset=True)
|
|
424
|
+
result_plan = LogicalPlan(
|
|
425
|
+
"analytics.plan/v1",
|
|
426
|
+
encoded,
|
|
427
|
+
hashlib.sha256(encoded.encode()).hexdigest(),
|
|
428
|
+
tuple(sorted(fields)),
|
|
429
|
+
tuple(sorted(types.items())),
|
|
430
|
+
tuple(columns.items()),
|
|
431
|
+
json.dumps(quality),
|
|
432
|
+
start,
|
|
433
|
+
end,
|
|
434
|
+
size,
|
|
435
|
+
step,
|
|
436
|
+
groups,
|
|
437
|
+
windows,
|
|
438
|
+
snapshot.row_count,
|
|
439
|
+
)
|
|
440
|
+
if has_matching or details.include:
|
|
441
|
+
from xp_libs_analytics.engine.backend import matching_cost
|
|
442
|
+
|
|
443
|
+
cost = matching_cost(result_plan, limits)
|
|
444
|
+
cost["requires_isolation"] = cost["requires_isolation"] or details.include
|
|
445
|
+
result_plan = replace(
|
|
446
|
+
result_plan,
|
|
447
|
+
matching_cost_json=json.dumps(cost),
|
|
448
|
+
diagnostic_key=hashlib.sha256(snapshot.handle.id.encode()).digest(),
|
|
449
|
+
)
|
|
450
|
+
return result_plan
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""A killable process with OS-enforced CPU/memory limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import multiprocessing as mp
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from xp_libs_analytics.engine.planner import LogicalPlan
|
|
11
|
+
from xp_libs_analytics.errors import AnalyticsError
|
|
12
|
+
from xp_libs_analytics.policy import Limits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def restrict_process(limits: Limits) -> Any:
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
import ctypes
|
|
18
|
+
from ctypes import wintypes
|
|
19
|
+
|
|
20
|
+
class Basic(ctypes.Structure):
|
|
21
|
+
_fields_ = [
|
|
22
|
+
("process_time", ctypes.c_int64),
|
|
23
|
+
("job_time", ctypes.c_int64),
|
|
24
|
+
("flags", wintypes.DWORD),
|
|
25
|
+
("min_ws", ctypes.c_size_t),
|
|
26
|
+
("max_ws", ctypes.c_size_t),
|
|
27
|
+
("active", wintypes.DWORD),
|
|
28
|
+
("affinity", ctypes.c_size_t),
|
|
29
|
+
("priority", wintypes.DWORD),
|
|
30
|
+
("scheduling", wintypes.DWORD),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
class IO(ctypes.Structure):
|
|
34
|
+
_fields_ = [
|
|
35
|
+
(name, ctypes.c_uint64)
|
|
36
|
+
for name in ("r_ops", "w_ops", "o_ops", "r_bytes", "w_bytes", "o_bytes")
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
class Extended(ctypes.Structure):
|
|
40
|
+
_fields_ = [
|
|
41
|
+
("basic", Basic),
|
|
42
|
+
("io", IO),
|
|
43
|
+
("process_memory", ctypes.c_size_t),
|
|
44
|
+
("job_memory", ctypes.c_size_t),
|
|
45
|
+
("peak_process", ctypes.c_size_t),
|
|
46
|
+
("peak_job", ctypes.c_size_t),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
50
|
+
kernel.CreateJobObjectW.restype = wintypes.HANDLE
|
|
51
|
+
kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
|
52
|
+
kernel.SetInformationJobObject.argtypes = [
|
|
53
|
+
wintypes.HANDLE,
|
|
54
|
+
ctypes.c_int,
|
|
55
|
+
ctypes.c_void_p,
|
|
56
|
+
wintypes.DWORD,
|
|
57
|
+
]
|
|
58
|
+
kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
59
|
+
kernel.GetCurrentProcess.restype = wintypes.HANDLE
|
|
60
|
+
handle = kernel.CreateJobObjectW(None, None)
|
|
61
|
+
info = Extended()
|
|
62
|
+
info.basic.flags = 0x100 | 0x2 # PROCESS_MEMORY | PROCESS_TIME
|
|
63
|
+
info.process_memory = limits.memory_bytes
|
|
64
|
+
info.basic.process_time = int(limits.timeout_seconds * 10_000_000)
|
|
65
|
+
if (
|
|
66
|
+
not handle
|
|
67
|
+
or not kernel.SetInformationJobObject(
|
|
68
|
+
handle, 9, ctypes.byref(info), ctypes.sizeof(info)
|
|
69
|
+
)
|
|
70
|
+
or not kernel.AssignProcessToJobObject(handle, kernel.GetCurrentProcess())
|
|
71
|
+
):
|
|
72
|
+
raise AnalyticsError("EXECUTION_BACKEND_ERROR")
|
|
73
|
+
return handle # Keep job alive for the process lifetime.
|
|
74
|
+
import importlib
|
|
75
|
+
|
|
76
|
+
resource = importlib.import_module("resource")
|
|
77
|
+
|
|
78
|
+
resource.setrlimit(resource.RLIMIT_AS, (limits.memory_bytes, limits.memory_bytes))
|
|
79
|
+
cpu = max(1, int(limits.timeout_seconds))
|
|
80
|
+
resource.setrlimit(resource.RLIMIT_CPU, (cpu, cpu))
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_worker(connection: Any, plan: LogicalPlan, limits: Limits) -> None:
|
|
85
|
+
os.environ["POLARS_MAX_THREADS"] = "2"
|
|
86
|
+
try:
|
|
87
|
+
job_handle = restrict_process(limits)
|
|
88
|
+
from xp_libs_analytics.engine.backend import execute_plan
|
|
89
|
+
|
|
90
|
+
result = execute_plan(plan, limits)
|
|
91
|
+
connection.send(result)
|
|
92
|
+
del job_handle
|
|
93
|
+
except MemoryError:
|
|
94
|
+
connection.send(AnalyticsError("RESOURCE_MEMORY_LIMIT").envelope())
|
|
95
|
+
except AnalyticsError as exc:
|
|
96
|
+
connection.send(exc.envelope())
|
|
97
|
+
except Exception:
|
|
98
|
+
connection.send(AnalyticsError("EXECUTION_BACKEND_ERROR").envelope())
|
|
99
|
+
finally:
|
|
100
|
+
connection.close()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def execute_isolated(plan: LogicalPlan, limits: Limits) -> dict[str, Any]:
|
|
104
|
+
context = mp.get_context("spawn")
|
|
105
|
+
receiver, sender = context.Pipe(duplex=False)
|
|
106
|
+
process = context.Process(target=run_worker, args=(sender, plan, limits), daemon=True)
|
|
107
|
+
process.start()
|
|
108
|
+
sender.close()
|
|
109
|
+
try:
|
|
110
|
+
try:
|
|
111
|
+
if not receiver.poll(limits.timeout_seconds):
|
|
112
|
+
raise AnalyticsError("RESOURCE_TIMEOUT")
|
|
113
|
+
return cast(dict[str, Any], receiver.recv())
|
|
114
|
+
except (EOFError, BrokenPipeError):
|
|
115
|
+
raise AnalyticsError("EXECUTION_BACKEND_ERROR") from None
|
|
116
|
+
finally:
|
|
117
|
+
receiver.close()
|
|
118
|
+
if process.is_alive():
|
|
119
|
+
process.terminate()
|
|
120
|
+
process.join(timeout=2)
|
|
121
|
+
process.close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Canonical, privacy-preserving public errors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from xp_libs_analytics.errors.codes import ErrorCode
|
|
10
|
+
|
|
11
|
+
CATALOG = MappingProxyType(json.loads(files(__package__).joinpath("catalog.json").read_text()))
|
|
12
|
+
|
|
13
|
+
RETRYABLE = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"MCP_UNAVAILABLE",
|
|
16
|
+
"RESOURCE_CONCURRENCY_LIMIT",
|
|
17
|
+
"RESOURCE_TIMEOUT",
|
|
18
|
+
"EXECUTION_BACKEND_ERROR",
|
|
19
|
+
"EXECUTION_CANCELLED",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AnalyticsError(Exception):
|
|
25
|
+
def __init__(self, code: str, path: str = "$", **details: Any) -> None:
|
|
26
|
+
self.code = ErrorCode(code)
|
|
27
|
+
self.path = path
|
|
28
|
+
self.details = details
|
|
29
|
+
super().__init__(CATALOG[code])
|
|
30
|
+
|
|
31
|
+
def envelope(self, query_id: str | None = None) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"version": "analytics.result/v1",
|
|
34
|
+
"query_id": query_id or uuid4().hex,
|
|
35
|
+
"status": "ERROR",
|
|
36
|
+
"error": {
|
|
37
|
+
"code": self.code.value,
|
|
38
|
+
"message": CATALOG[self.code],
|
|
39
|
+
"path": self.path,
|
|
40
|
+
"retryable": self.code in RETRYABLE,
|
|
41
|
+
"details": self.details,
|
|
42
|
+
},
|
|
43
|
+
}
|