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.
Files changed (65) hide show
  1. xp_libs_analytics/__init__.py +18 -0
  2. xp_libs_analytics/engine/__init__.py +1 -0
  3. xp_libs_analytics/engine/backend.py +379 -0
  4. xp_libs_analytics/engine/planner.py +450 -0
  5. xp_libs_analytics/engine/worker.py +121 -0
  6. xp_libs_analytics/errors/__init__.py +43 -0
  7. xp_libs_analytics/errors/catalog.json +75 -0
  8. xp_libs_analytics/errors/codes.py +81 -0
  9. xp_libs_analytics/ingest/__init__.py +309 -0
  10. xp_libs_analytics/interfaces.py +70 -0
  11. xp_libs_analytics/matching/__init__.py +1 -0
  12. xp_libs_analytics/matching/compiler.py +344 -0
  13. xp_libs_analytics/matching/diagnostics.py +115 -0
  14. xp_libs_analytics/matching/models.py +98 -0
  15. xp_libs_analytics/matching/normalize.py +16 -0
  16. xp_libs_analytics/matching/patterns.py +71 -0
  17. xp_libs_analytics/mcp/__init__.py +1 -0
  18. xp_libs_analytics/mcp/server.py +248 -0
  19. xp_libs_analytics/models/__init__.py +654 -0
  20. xp_libs_analytics/parser/__init__.py +142 -0
  21. xp_libs_analytics/policy/__init__.py +69 -0
  22. xp_libs_analytics/py.typed +0 -0
  23. xp_libs_analytics/registry.py +204 -0
  24. xp_libs_analytics/sdk/__init__.py +259 -0
  25. xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
  26. xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
  27. xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
  28. xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
  29. xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
  30. xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
  31. xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
  32. xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
  33. xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
  34. xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
  35. xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
  36. xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
  37. xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
  38. xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
  39. xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
  40. xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
  41. xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
  42. xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
  43. xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
  44. xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
  45. xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
  46. xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
  47. xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
  48. xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
  49. xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
  50. xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
  51. xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
  52. xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
  53. xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
  54. xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
  55. xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
  56. xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
  57. xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
  58. xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
  59. xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
  60. xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
  61. xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
  62. xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
  63. xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
  64. xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
  65. 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
+ }