From e8344fd30a9c1b892c39ad3436234365765657bb Mon Sep 17 00:00:00 2001 From: ajanbekzat Date: Mon, 13 Jul 2026 14:25:24 +0000 Subject: [PATCH 1/2] feat(nori_regression): add Nori tabular regression plugin Add a scheduled and HTTP Processing Engine plugin that predicts a numeric field in an InfluxDB 3 measurement from other columns with Synthefy's Nori in-context tabular regression model, via the Baseten gateway. It trains on the rows where the target field is present and predicts (imputes) the rows where it is null, writing the predictions back with write_sync. The Nori API key is read only from a request header or the NORI_API_KEY env var, never from trigger arguments or the request body. The model slug is configurable (synthefy/nori, synthefy/nori-30m). Ships with README, manifest.toml, requirements.txt, and a plugin_library.json entry. --- influxdata/library/plugin_library.json | 11 + influxdata/nori_regression/README.md | 321 ++++++++++++++ influxdata/nori_regression/manifest.toml | 19 + influxdata/nori_regression/nori_regression.py | 397 ++++++++++++++++++ influxdata/nori_regression/requirements.txt | 3 + 5 files changed, 751 insertions(+) create mode 100644 influxdata/nori_regression/README.md create mode 100644 influxdata/nori_regression/manifest.toml create mode 100644 influxdata/nori_regression/nori_regression.py create mode 100644 influxdata/nori_regression/requirements.txt diff --git a/influxdata/library/plugin_library.json b/influxdata/library/plugin_library.json index bb8986b..2a44378 100644 --- a/influxdata/library/plugin_library.json +++ b/influxdata/library/plugin_library.json @@ -340,6 +340,17 @@ "last_update": "2026-01-08", "trigger_types_supported": ["http"] }, + { + "name": "Nori Regression", + "path": "influxdata/nori_regression/nori_regression.py", + "description": "[BETA] Requires InfluxDB 3.8.2+. Predict a numeric field in an InfluxDB 3 measurement from other columns with Nori, Synthefy's in-context-learning tabular regression model, via the Baseten gateway. Trains on the rows where the target field is present and predicts (imputes) the rows where it is null, writing the predictions back to InfluxDB. Scheduled and HTTP triggers; the model gateway slug is configurable (synthefy/nori or synthefy/nori-30m).", + "author": "Synthefy", + "docs_file_link": "https://github.com/influxdata/influxdb3_plugins/blob/main/influxdata/nori_regression/README.md", + "required_plugins": [], + "required_libraries": ["requests", "pandas", "numpy"], + "last_update": "2026-07-15", + "trigger_types_supported": ["scheduler", "http"] + }, { "name": "SageMaker Inference", "path": "influxdata/sagemaker/sagemaker.py", diff --git a/influxdata/nori_regression/README.md b/influxdata/nori_regression/README.md new file mode 100644 index 0000000..b80845d --- /dev/null +++ b/influxdata/nori_regression/README.md @@ -0,0 +1,321 @@ +# Nori Regression Plugin + +⚡ scheduled, http 🏷️ regression, tabular, machine-learning 🔧 InfluxDB 3 Core, InfluxDB 3 Enterprise + +Predict a numeric field in an InfluxDB 3 measurement from other columns with **Nori**, Synthefy's +in-context-learning tabular regression model, called through the Synthefy inference gateway. The +plugin reads a window of rows, trains on the rows where the target field is present, predicts the +rows where it is null (imputation / backfill), and writes the predicted values back into InfluxDB. + +## Description + +Nori is a tabular regression foundation model: you give it labeled feature rows (`X_train`, +`y_train`) and query rows (`X_test`) in a single request, and it predicts a value for each query row +in one forward pass, with no training or fine-tuning. + +This plugin applies Nori to an InfluxDB measurement. You choose a target field and a set of feature +columns; the plugin uses the rows where the target is present as the in-context training set and +predicts the target for the rows where it is null, writing each prediction back at its own row's +timestamp. It is plain tabular regression: Nori sees only the feature columns you name, with no time +or ordering assumptions. + +Typical uses: + +- Backfill a field that dropped out (a sensor went offline while its neighbors kept reporting). +- Impute a missing metric from correlated ones (for example, predict `pressure` from `temperature` + and `humidity`). +- Derive a field that is expensive to measure directly from cheaper ones recorded alongside it. + +Key features: + +- **In-context tabular regression**: no training step; the recent labeled rows are the context. +- **Imputation / backfill**: predicts the rows where the target is null and writes them back. +- **Scheduled or on-demand**: run on an interval (scheduled trigger) or via an HTTP endpoint. +- **Tag filtering**: operate on a single series selected by tags. +- **Writes results back** to InfluxDB as a new measurement using line protocol. + +## Configuration + +### Plugin metadata + +This plugin includes a JSON metadata schema in its docstring that declares the supported trigger +types (`scheduled`, `http`) and their arguments, so the InfluxDB 3 Explorer UI can render a +configuration form. + +### Authentication for the Nori gateway + +The Nori gateway API key is a secret and is **never** read from trigger arguments or the request +body (both are logged). It is resolved, in order: + +1. an incoming `X-Nori-Api-Key: ` request header (HTTP trigger only), then +2. the `NORI_API_KEY` environment variable set on the InfluxDB host (required for the scheduled + trigger). + +The key is intentionally **not** accepted in the `Authorization` header: InfluxDB parses +`Authorization` for its own request authorization, so a key placed there never reaches the plugin. +Use the custom `X-Nori-Api-Key` header instead. + +Get a Nori API key from the [Synthefy console](https://console.synthefy.com/). A single key works +for all Nori model variants (see [Supported models](#supported-models)), so you do not need a +separate key per variant. This plugin does not create keys. + +### Scheduled trigger parameters + +| Parameter | Default | Description | +|---|---|---| +| `measurement` | (required) | Source measurement (table) to read from. | +| `field` | (required) | The numeric field to predict (the regression target). The plugin trains on rows where it is present and predicts the rows where it is null. | +| `feature_fields` | (required) | Numeric feature columns (X) used to predict `field`, **space-separated** (e.g. `temp humidity`). Use spaces, not commas (`--trigger-arguments` splits argument pairs on commas) and not dots (field names may contain a `.`). In the HTTP JSON body it may also be a list. | +| `window` | `30d` | Time window of rows to read. Units: `s,min,h,d,w`. | +| `tags` | (none) | Filter to a single series. Format: `key:val key2:val2` (space-separated pairs). Required if the measurement holds more than one series (see [Notes](#notes)). | +| `model` | `synthefy/nori` | The Nori gateway slug to call. Your API key must be granted this slug. | +| `gateway_url` | `https://inference.baseten.co/predict` | Nori gateway endpoint. | +| `output_measurement` | `_regressed` | Measurement to write predictions to. | +| `target_database` | (trigger db) | Write predictions to a different database. | +| `dry_run` | `false` | If `true`, log predictions but do not write them. | +| `min_history` | `50` | Minimum labeled rows (target present) required to train; skip below this. | + +### HTTP trigger parameters + +All scheduled parameters are accepted (in the JSON request body or as trigger arguments), plus: + +| Parameter | Default | Description | +|---|---|---| +| `start_time` | (none) | ISO start of the window. Overrides `window`. | +| `end_time` | (none) | ISO end of the window. | + +Scalar values in the JSON body override trigger arguments. A `tags` object may be sent as JSON +(`{"tags": {"site": "A"}}`), and `feature_fields` may be sent as a JSON list +(`{"feature_fields": ["temp", "humidity"]}`) or a space-separated string. + +## Requirements + +### Dependencies + +- `requests` +- `pandas` +- `numpy` + +### Installation steps + +1. Install the Python dependencies into the InfluxDB 3 Processing Engine environment: + + ```bash + influxdb3 install package requests pandas numpy + ``` + +2. Reference the plugin directly from this repository with the `gh:` prefix (the form used in the + examples below): `--path "gh:influxdata/nori_regression/nori_regression.py"`. Alternatively, copy + `nori_regression.py` into your plugin directory (the one passed to `influxdb3 serve + --plugin-dir`) and use `--path nori_regression.py`. + +3. Set the Nori gateway key on the InfluxDB host. Get your key from the + [Synthefy console](https://console.synthefy.com/) (one key works for all Nori variants): + + ```bash + export NORI_API_KEY="" + ``` + +### Prerequisites + +- InfluxDB 3 Core or Enterprise (>= 3.8.2) with the Processing Engine enabled. +- A Nori API key from the [Synthefy console](https://console.synthefy.com/). +- A measurement with a numeric target field, one or more numeric feature columns, and enough labeled + rows (`>= min_history`) where the target is present. + +## Trigger setup + +### Scheduled trigger + +Every 15 minutes, fill any rows of `sensors` (for `site=A`) that are missing `pressure`, predicting +it from `temp` and `humidity`: + +```bash +influxdb3 create trigger \ + --database mydb \ + --path "gh:influxdata/nori_regression/nori_regression.py" \ + --trigger-spec "every:15m" \ + --trigger-arguments measurement=sensors,field=pressure,feature_fields="temp humidity",tags=site:A,model=synthefy/nori \ + nori_sensors_pressure +``` + +### HTTP trigger + +```bash +influxdb3 create trigger \ + --database mydb \ + --path "gh:influxdata/nori_regression/nori_regression.py" \ + --trigger-spec "request:nori_regress" \ + nori_http +``` + +## Example usage + +### On-demand HTTP regression + +Call the HTTP endpoint (exposed at `/api/v3/engine/`), passing the Nori key in the header: + +```bash +curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ + -H "X-Nori-Api-Key: $NORI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"measurement":"sensors","field":"pressure","feature_fields":["temp","humidity"],"tags":{"site":"A"}}' +``` + +**Expected output:** + +```json +{"status": "success", "task_id": "...", "result": {"status": "success", "written": 24}} +``` + +The predicted values are written to the `sensors_regressed` measurement. + +### Backfill a specific window + +```bash +curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ + -H "X-Nori-Api-Key: $NORI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"measurement":"sensors","field":"pressure","feature_fields":["temp","humidity"],"tags":{"site":"A"},"start_time":"2026-01-01T00:00:00Z","end_time":"2026-02-01T00:00:00Z"}' +``` + +### Dry run (preview without writing) + +Set `dry_run=true` to log the predictions without writing them back: + +```bash +curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ + -H "X-Nori-Api-Key: $NORI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"measurement":"sensors","field":"pressure","feature_fields":["temp","humidity"],"tags":{"site":"A"},"dry_run":true}' +``` + +## Output format + +Each prediction is written as a point: + +- **Measurement:** `output_measurement` (default `_regressed`). +- **Tags:** `model` (the slug), `source` (the input measurement), `target` (the predicted field), + and any single-value filter tags. +- **Field:** `value` (float): the predicted target value. +- **Timestamp:** the predicted row's own timestamp (nanoseconds). + +Example line protocol: + +``` +sensors_regressed,model=synthefy/nori,source=sensors,target=pressure value=1001.2 1704672000000000000 +``` + +## Querying predictions + +```sql +SELECT time, value, model, target FROM sensors_regressed ORDER BY time DESC LIMIT 24; +``` + +## Notes + +- **What it predicts:** rows in the window where the target `field` is null but every + `feature_fields` column is present. Rows where the target is already present become the training + set (`X_train`/`y_train`). If no rows are missing the target, the run is a no-op (`skipped`). It + does not overwrite existing target values. +- **One series per run:** predictions are written back at each row's own timestamp, so a run must + resolve to a single series. If the measurement holds several series (for example one per `site`) + and your `tags` filter does not isolate one, two rows can share a timestamp and their output + points would collide, so the plugin **fails with a clear message** rather than silently dropping + values. Add a `tags` filter, or run one trigger per series. +- **Features only:** Nori sees just the columns you name in `feature_fields`. Row order does not + matter, and no time-derived features are added. + +## Supported models + +The `model` argument is the Nori gateway slug your API key is granted: + +- `synthefy/nori`: the base Nori in-context tabular regression model (default). +- `synthefy/nori-30m`: a 30M-parameter Nori variant. + +A single API key from the [Synthefy console](https://console.synthefy.com/) works for all of these +variants. + +## Code overview + +### Files + +- `nori_regression.py`: the plugin (metadata docstring and implementation). +- `requirements.txt`: Python dependencies. +- `manifest.toml`: packaging metadata. + +### Key functions + +- `process_scheduled_call(influxdb3_local, call_time, args)`: scheduled entry point. +- `process_request(influxdb3_local, query_parameters, request_headers, request_body, args)`: HTTP entry point. +- `_query(influxdb3_local, cfg, task_id)`: reads the target and feature columns for the window into a DataFrame. +- `_regress(influxdb3_local, cfg, api_key, task_id)`: splits labeled and null-target rows and builds `X_train`/`y_train`/`X_test`. +- `_call_nori(...)`: sends the in-context regression request (`task="regression"`) to the gateway. +- `_write_predictions(...)`: writes the predictions back with `write_sync`. + +## Troubleshooting + +### Common issues + +### Missing API key + +The plugin cannot find a Nori gateway key. + +**Solution:** set `NORI_API_KEY` on the InfluxDB host, or pass an `X-Nori-Api-Key: ` header +when calling the HTTP trigger (see [Authentication](#authentication-for-the-nori-gateway)). + +### Model API not found (404) + +The gateway rejects the `model` slug for your key. + +**Solution:** confirm the `model` slug is spelled correctly (for example `synthefy/nori` or +`synthefy/nori-30m`) and that you passed a valid API key from the +[Synthefy console](https://console.synthefy.com/). + +### Not enough labeled rows, or nothing to predict + +- **`only N labeled rows (< min_history)`:** fewer than `min_history` rows have the target present. + Widen `window`, lower `min_history`, or check that `measurement`/`field`/`feature_fields`/`tags` + select data. +- **`no rows to predict`:** every target value in the window is already present. The plugin only + fills rows where the target is null; there is nothing to impute. + +### Multiple series sharing timestamps + +The measurement holds more than one series and your `tags` filter did not isolate one, so +predictions would collide on write. + +**Solution:** add a `tags` filter that selects a single series, or run one trigger per series. + +### Feature field not found + +A `feature_fields` column does not exist in the measurement, or it clashes with the target field or +the reserved names `time`/`y` (not allowed). + +**Solution:** fix the column names in `feature_fields`. + +### Cold-start latency on the first call + +If the model has scaled to zero, the first request after idle can be slow (about 60-70 seconds) or +return a 500 once. + +**Solution:** retry; the scheduled trigger recovers on the next tick. Pre-warm with a direct call +before time-sensitive use. + +## Limitations + +- One series per run; run multiple triggers for multiple series (the plugin fails loud if a run + resolves to more than one series). Multi-series imputation in a single run is a possible + enhancement. +- Imputes only rows where the target is null; it does not overwrite existing values. +- Prediction quality depends on how well `feature_fields` explain the target. +- Each call is a billed gateway request; a larger `window` sends more rows and tokens. + +## License + +Apache 2.0. + +## Questions/Comments + +Please open an issue or discussion in the +[influxdb3_plugins](https://github.com/influxdata/influxdb3_plugins) repository. diff --git a/influxdata/nori_regression/manifest.toml b/influxdata/nori_regression/manifest.toml new file mode 100644 index 0000000..a0b795d --- /dev/null +++ b/influxdata/nori_regression/manifest.toml @@ -0,0 +1,19 @@ +manifest_schema_version = "1.2" + +[plugin] +name = "nori_regression" +version = "0.1.0" +description = "Predict a numeric InfluxDB 3 field from other columns with Synthefy's Nori in-context tabular regression model (via the Baseten gateway). Imputes rows where the target field is null." +triggers = ["process_scheduled_call", "process_request"] +homepage = "https://www.influxdata.com/" +repository = "https://github.com/influxdata/influxdb3_plugins/tree/main/influxdata/nori_regression" +documentation = "https://github.com/influxdata/influxdb3_plugins/blob/main/influxdata/nori_regression/README.md" +exclude = [ + ".git/", ".venv/", "__pycache__/", "*.pyc", "tests/**", + ".claude/", ".vscode/", ".idea/", ".DS_Store", + "*.py", "!nori_regression.py", +] + +[dependencies] +database_version = ">=3.8.2" +python = ["requests", "pandas", "numpy"] diff --git a/influxdata/nori_regression/nori_regression.py b/influxdata/nori_regression/nori_regression.py new file mode 100644 index 0000000..7ad3a1d --- /dev/null +++ b/influxdata/nori_regression/nori_regression.py @@ -0,0 +1,397 @@ +""" +{ + "plugin_type": ["scheduled", "http"], + "scheduled_args_config": [ + {"name": "measurement", "example": "sensors", "description": "Source measurement (table) to read from.", "required": true}, + {"name": "field", "example": "pressure", "description": "The numeric field to predict (the regression target y). The plugin trains on rows where this field is present and predicts the rows where it is null.", "required": true}, + {"name": "feature_fields", "example": "temp humidity", "description": "Numeric feature columns (X) used to predict `field`, separated by spaces (e.g. 'temp humidity'). Use spaces, not commas: --trigger-arguments splits argument pairs on commas. In the HTTP JSON body this may also be a list. Required.", "required": true}, + {"name": "window", "example": "30d", "description": "Time window of rows to read from InfluxDB. Units: s,min,h,d,w.", "required": false}, + {"name": "tags", "example": "site:A", "description": "Filter to a single series. Format: key:val key2:val2 (space-separated pairs, single value per key). Required if the measurement holds more than one series.", "required": false}, + {"name": "model", "example": "synthefy/nori", "description": "The Nori gateway slug to call. Available slugs: synthefy/nori (default) and synthefy/nori-30m (a 30M-parameter variant). Your API key must be granted this slug.", "required": false}, + {"name": "gateway_url", "example": "https://inference.baseten.co/predict", "description": "Nori gateway endpoint.", "required": false}, + {"name": "output_measurement", "example": "sensors_regressed", "description": "Where to write predictions. Default: _regressed.", "required": false}, + {"name": "target_database", "example": "predictions", "description": "Write predictions to this database instead of the trigger's own.", "required": false}, + {"name": "dry_run", "example": "false", "description": "If true, log predictions but do not write them.", "required": false}, + {"name": "min_history", "example": "50", "description": "Minimum labeled rows (target present) required to train; abort below this.", "required": false} + ], + "http_args_config": [ + {"name": "measurement", "example": "sensors", "description": "Source measurement. May also be provided in the JSON request body.", "required": false}, + {"name": "field", "example": "pressure", "description": "Target field to predict. May also be in the request body.", "required": false}, + {"name": "feature_fields", "example": "temp humidity", "description": "Feature columns. In the JSON request body this may be a JSON list of column names, or a space-separated string.", "required": false}, + {"name": "start_time", "example": "2026-01-01T00:00:00Z", "description": "Optional ISO start of the window (overrides `window`).", "required": false}, + {"name": "end_time", "example": "2026-02-01T00:00:00Z", "description": "Optional ISO end of the window.", "required": false} + ] +} +""" + +import json +import os +import random +import re +import time +import uuid +from datetime import timedelta + +import numpy as np +import pandas as pd +import requests + +# --- Nori gateway defaults ------------------------------------------------- +# The gateway routes by the `model` slug in the body; the OUTGOING request to the gateway +# authenticates with an `Authorization: Api-Key ` header (see _call_nori). The key is a +# SECRET: the plugin reads it from the NORI_API_KEY environment variable on the InfluxDB host, or +# (HTTP trigger only) from an incoming `X-Nori-Api-Key` request header. It is NEVER read from +# trigger args or the request body (which get logged), and never from the incoming `Authorization` +# header (InfluxDB consumes that header for its own request authorization). +DEFAULT_GATEWAY_URL = "https://inference.baseten.co/predict" +DEFAULT_MODEL_SLUG = "synthefy/nori" +API_KEY_ENV_VAR = "NORI_API_KEY" + +_UNIT_SECONDS = {"s": 1, "min": 60, "h": 3600, "d": 86400, "w": 604800} + + +def _interval_to_timedelta(text: str) -> timedelta: + """Parse '30d' / '15min' / '1h' into a timedelta (also used to validate the `window` arg).""" + text = str(text).strip() + num, unit = "", "" + for ch in text: + (num := num + ch) if (ch.isdigit() or ch == ".") else (unit := unit + ch) + if not num: + raise ValueError(f"interval {text!r} has no number (expected e.g. '30d', '15min', '1h')") + if unit not in _UNIT_SECONDS: + raise ValueError(f"bad interval unit in {text!r} (use s/min/h/d/w)") + return timedelta(seconds=float(num) * _UNIT_SECONDS[unit]) + + +def _ident(name: str) -> str: + """Quote a SQL identifier, escaping embedded double-quotes. + + Identifiers (measurement / field / tag-key names) cannot be passed as query parameters, so + they are interpolated, but quoted and escaped so a name containing a quote can neither break + the query nor inject SQL. Tag/time *values* are passed as bound parameters (see _where_clause). + """ + return '"' + str(name).replace('"', '""') + '"' + + +def _get_api_key(request_headers=None) -> str: + """Resolve the gateway key: the incoming `X-Nori-Api-Key` header wins (HTTP trigger), else the + NORI_API_KEY env var. + + The key is deliberately NOT read from the incoming `Authorization` header: InfluxDB parses that + header for its own request authorization, so a custom scheme there does not reach the plugin. + """ + if request_headers: + for k, v in request_headers.items(): + if k.lower() == "x-nori-api-key": # case-insensitive per server + return v.strip() + key = os.environ.get(API_KEY_ENV_VAR, "").strip() + if not key: + raise ValueError( + f"No Nori API key. Set the {API_KEY_ENV_VAR} env var on the InfluxDB host, " + f"or pass an 'X-Nori-Api-Key: ' header (HTTP trigger)." + ) + return key + + +def _split_list(text) -> list: + """Parse a space- (or comma-) separated arg into a list of trimmed, non-empty strings. + + NOT '.'-separated: InfluxDB field names may contain dots. Prefer spaces in `--trigger-arguments` + (the CLI splits argument pairs on commas, so a comma inside a value breaks parsing); commas are + fine inside the HTTP JSON body. + """ + return [x for x in re.split(r"[,\s]+", str(text).strip()) if x] + + +def _build_config(args: dict) -> dict: + """Parse trigger args (strings) into a typed config with defaults.""" + args = args or {} + measurement = args.get("measurement") + field = args.get("field") + tags = {} + if args.get("tags"): + for pair in args["tags"].split(): # space-separated key:val pairs (a value may contain '.') + if ":" in pair: + k, v = pair.split(":", 1) + tags[k.strip()] = v.strip() + return { + "measurement": measurement, + "field": field, + # feature columns (X); space-separated (see _split_list). dict.fromkeys de-duplicates while + # preserving order (a repeated column would otherwise produce a duplicate SQL projection). + "feature_fields": list(dict.fromkeys(_split_list(args.get("feature_fields", "")))), + "tags": tags, + "window": args.get("window", "30d"), + "model": args.get("model", DEFAULT_MODEL_SLUG), + "gateway_url": args.get("gateway_url", DEFAULT_GATEWAY_URL), + "output_measurement": args.get("output_measurement") or (f"{measurement}_regressed" if measurement else None), + "target_database": args.get("target_database") or None, + "dry_run": str(args.get("dry_run", "false")).lower() == "true", + "min_history": int(args.get("min_history", 50)), + "start_time": args.get("start_time"), + "end_time": args.get("end_time"), + } + + +def _validate_columns(influxdb3_local, cfg) -> None: + """Fail with a clear message if the measurement / target / tag / feature columns do not exist. + + Uses a parameterized information_schema query, so a typo yields 'field X not found' instead of + an opaque SQL error or a silent empty result. + """ + rows = influxdb3_local.query( + "SELECT column_name FROM information_schema.columns WHERE table_name = $m", + {"m": cfg["measurement"]}, + ) + cols = {r.get("column_name") for r in (rows or [])} + if not cols: + raise ValueError(f"measurement {cfg['measurement']!r} not found (no columns)") + if cfg["field"] not in cols: + raise ValueError(f"target field {cfg['field']!r} not found in {cfg['measurement']!r}") + missing = [k for k in cfg["tags"] if k not in cols] + if missing: + raise ValueError(f"tag column(s) {missing} not found in {cfg['measurement']!r}") + if not cfg["feature_fields"]: + raise ValueError("`feature_fields` is required (the '.'/','-separated feature columns X)") + reserved = [f for f in cfg["feature_fields"] if f in ("time", "y") or f == cfg["field"]] + if reserved: + raise ValueError( + f"feature_fields cannot include the target field or the reserved names time/y: {reserved}" + ) + missing_f = [f for f in cfg["feature_fields"] if f not in cols] + if missing_f: + raise ValueError(f"feature field(s) {missing_f} not found in {cfg['measurement']!r}") + + +def _where_clause(cfg): + """Build the WHERE clause (time window + tag filters) with bound parameters. + + Tag and time-range *values* are bound parameters (never string-concatenated) and identifiers are + quote-escaped, so quotes in values/names can't break or inject the query. Returns + (time_clause, tag_clause, params). + """ + params: dict = {} + tag_clause = "" + for i, (k, v) in enumerate(cfg["tags"].items()): + p = f"tag{i}" + tag_clause += f" AND {_ident(k)} = ${p}" + params[p] = v + if cfg["start_time"] and cfg["end_time"]: + time_clause = "time >= $start_ts AND time < $end_ts" + params["start_ts"] = cfg["start_time"] + params["end_ts"] = cfg["end_time"] + else: + # An INTERVAL literal can't be bound, so validate `window` is a clean interval (digits + + # a known unit, no quotes possible) before interpolating it. + _interval_to_timedelta(cfg["window"]) + time_clause = f"time > now() - INTERVAL '{cfg['window']}'" + return time_clause, tag_clause, params + + +def _query(influxdb3_local, cfg, task_id) -> pd.DataFrame: + """Read (time, target y, feature columns) for the window into a DataFrame. + + A row whose target is null but whose features are present is a prediction target (impute). + Values are coerced to numeric; non-finite values become NaN so they cannot reach the gateway. + """ + feats = cfg["feature_fields"] + time_clause, tag_clause, params = _where_clause(cfg) + feat_cols = ", ".join(_ident(f) for f in feats) + sql = ( + f"SELECT time, {_ident(cfg['field'])} AS y, {feat_cols} " + f"FROM {_ident(cfg['measurement'])} " + f"WHERE {time_clause}{tag_clause} " + f"ORDER BY time" + ) + influxdb3_local.info(f"[{task_id}] query: {sql}") + rows = influxdb3_local.query(sql, params) if params else influxdb3_local.query(sql) + if not rows: + return pd.DataFrame(columns=["time", "y", *feats]) + df = pd.DataFrame(rows) + df["time"] = pd.to_datetime(df["time"], utc=True, errors="coerce") + df = df.dropna(subset=["time"]).sort_values("time").reset_index(drop=True) + df["y"] = pd.to_numeric(df["y"], errors="coerce").replace([np.inf, -np.inf], np.nan) + for f in feats: + df[f] = (pd.to_numeric(df[f], errors="coerce").replace([np.inf, -np.inf], np.nan) + if f in df.columns else np.nan) + return df + + +def _call_nori(influxdb3_local, cfg, X_train, y_train, X_test, api_key, task_id): + """Send an in-context regression request to the Nori gateway and return the predictions list. + + Raises on a transport error or an unexpected response shape. + """ + n_features = len(X_train[0]) if X_train else 0 + influxdb3_local.info( + f"[{task_id}] calling Nori: model={cfg['model']} " + f"n_features={n_features} n_train={len(X_train)} n_test={len(X_test)}" + ) + payload = {"model": cfg["model"], "task": "regression", + "X_train": X_train, "y_train": y_train, "X_test": X_test} + resp = requests.post( + cfg["gateway_url"], + json=payload, + headers={"Content-Type": "application/json", "Authorization": f"Api-Key {api_key}"}, + timeout=120, + ) + resp.raise_for_status() + result = resp.json() + preds = result.get("predictions") + if not isinstance(preds, list) or len(preds) != len(X_test): + raise ValueError(f"unexpected Nori response: {str(result)[:300]}") + influxdb3_local.info(f"[{task_id}] Nori usage={result.get('usage', {})}") + return preds + + +def _regress(influxdb3_local, cfg, api_key, task_id): + """Predict the target `field` from `feature_fields` on the same rows (tabular regression). + + Trains on rows where the target is present and predicts the rows where it is null (impute / + backfill), returning (timestamps_of_predicted_rows, predictions). No time features and no + ordering assumptions: Nori sees only the feature columns. + """ + feats = cfg["feature_fields"] + df = _query(influxdb3_local, cfg, task_id) + df = df.dropna(subset=feats) # a row needs all features present to train on or predict + train = df[df["y"].notna()] + test = df[df["y"].isna()] + if len(train) < cfg["min_history"]: + influxdb3_local.warn( + f"[{task_id}] only {len(train)} labeled rows (< min_history {cfg['min_history']}); skipping" + ) + return [], [] + if len(test) == 0: + influxdb3_local.warn( + f"[{task_id}] no rows to predict: every '{cfg['field']}' value in the window is already " + f"present (this plugin fills rows where the target is null); skipping" + ) + return [], [] + # Predictions are written back at each test row's timestamp. If two rows share a timestamp + # (a multi-series measurement not isolated by `tags`), their output points would have an + # identical measurement+tag+time key and silently overwrite each other, so fail loud instead. + dup = pd.Index(test["time"])[pd.Index(test["time"]).duplicated()].unique() + if len(dup): + raise ValueError( + f"matched multiple series sharing {len(dup)} timestamp(s), so writing predictions " + f"back would collide and silently drop values. Add a `tags` filter that isolates a " + f"single series (or run one trigger per series). First colliding time: " + f"{pd.Timestamp(dup[0])}" + ) + X_train = train[feats].to_numpy().tolist() + y_train = train["y"].to_numpy().tolist() + X_test = test[feats].to_numpy().tolist() + preds = _call_nori(influxdb3_local, cfg, X_train, y_train, X_test, api_key, task_id) + return list(pd.DatetimeIndex(test["time"])), preds + + +class _BatchLines: + """Wrap several LineBuilder objects so write_sync / write_sync_to_db can write them in one call. + + build() returns the newline-joined line protocol; each LineBuilder exposes its own build(). + """ + + def __init__(self, line_builders): + self._line_builders = list(line_builders) + + def build(self) -> str: + lines = [str(b.build()) for b in self._line_builders] + if not lines: + raise ValueError("no line protocol to write") + return "\n".join(lines) + + +def _write_predictions(influxdb3_local, cfg, out_times, preds, task_id, max_retries=3) -> int: + """Write predictions with write_sync so write errors surface during trigger execution. + + Buffered write()/write_to_db() only accumulate points and flush after the trigger returns, so + the plugin never learns whether the write succeeded. write_sync/write_sync_to_db (InfluxDB + 3.8.2+) write immediately and raise on failure; the points are batched into one line-protocol + payload. On repeated failure this raises, so the caller reports "failed" rather than a bogus + success count. + """ + builders = [] + for ts, value in zip(out_times, preds): + if value is None: + continue + line = (LineBuilder(cfg["output_measurement"]) + .tag("model", cfg["model"]) + .tag("source", cfg["measurement"]) + .tag("target", cfg["field"])) # the predicted field + for k, v in cfg["tags"].items(): # echo single-value filter tags + line = line.tag(k, v) + line = line.float64_field("value", float(value)) + line = line.time_ns(int(pd.Timestamp(ts).value)) # pandas ns since epoch + builders.append(line) + if not builders: + return 0 + batch = _BatchLines(builders) + db = cfg["target_database"] + for attempt in range(max_retries): + try: + if db: + influxdb3_local.write_sync_to_db(db, batch, no_sync=True) + else: + influxdb3_local.write_sync(batch, no_sync=True) + return len(builders) + except Exception as e: # noqa: BLE001 + if attempt < max_retries - 1: + time.sleep((2 ** attempt) + random.random()) + else: + influxdb3_local.error(f"[{task_id}] write_sync failed after {max_retries} attempts: {e}") + raise + return 0 + + +def _run(influxdb3_local, cfg, api_key, task_id) -> dict: + if not cfg["measurement"] or not cfg["field"]: + raise ValueError("`measurement` and `field` are required") + _validate_columns(influxdb3_local, cfg) + out_times, preds = _regress(influxdb3_local, cfg, api_key, task_id) + if not preds: + return {"status": "skipped", "written": 0} + if cfg["dry_run"]: + influxdb3_local.info(f"[{task_id}] dry_run: first preds={preds[:5]}") + return {"status": "dry_run", "predictions": preds} + written = _write_predictions(influxdb3_local, cfg, out_times, preds, task_id) + influxdb3_local.info(f"[{task_id}] wrote {written} predictions to {cfg['output_measurement']}") + return {"status": "success", "written": written} + + +# --- Entry points ---------------------------------------------------------- + +def process_scheduled_call(influxdb3_local, call_time, args=None): + task_id = str(uuid.uuid4()) + try: + cfg = _build_config(args) + api_key = _get_api_key() # scheduled: no request headers -> env var only + _run(influxdb3_local, cfg, api_key, task_id) + except Exception as e: # noqa: BLE001 - never let a scheduled run crash the engine + influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {e}") + + +def process_request(influxdb3_local, query_parameters, request_headers, request_body, args=None): + task_id = str(uuid.uuid4()) + try: + body = {} + if request_body: + body = json.loads(request_body) + # scalar body values override trigger args + merged = {**(args or {}), **{k: str(v) for k, v in body.items() if not isinstance(v, (list, dict))}} + cfg = _build_config(merged) + # complex body values are dropped by the scalar filter above, so handle them explicitly: + if isinstance(body.get("tags"), dict): + cfg["tags"] = {str(k): str(v) for k, v in body["tags"].items()} + if isinstance(body.get("feature_fields"), list): + cfg["feature_fields"] = list(dict.fromkeys(str(x) for x in body["feature_fields"])) + api_key = _get_api_key(request_headers) + result = _run(influxdb3_local, cfg, api_key, task_id) + # surface the real outcome at the top level (success / skipped / dry_run), not always + # "success", so a caller can tell a real prediction from a no-op. + return {"status": result.get("status", "success"), "task_id": task_id, "result": result} + except json.JSONDecodeError: + influxdb3_local.error(f"[{task_id}] invalid JSON body") + return {"status": "failed", "message": "invalid JSON in request body"} + except Exception as e: # noqa: BLE001 + influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {e}") + return {"status": "failed", "message": str(e)} diff --git a/influxdata/nori_regression/requirements.txt b/influxdata/nori_regression/requirements.txt new file mode 100644 index 0000000..6d71a56 --- /dev/null +++ b/influxdata/nori_regression/requirements.txt @@ -0,0 +1,3 @@ +requests +pandas +numpy From 59665180fb56354c5c7be7528a779815ca2c5dcc Mon Sep 17 00:00:00 2001 From: Bekzat Ajan Date: Fri, 31 Jul 2026 19:49:29 +0500 Subject: [PATCH 2/2] fix(nori_regression): address review, harden config and gateway handling Addresses the round-two review on #115, plus two defects found while re-testing against the live gateway. Correctness - Default model slug was `synthefy/nori`, which is retired and returns 404, so the default configuration could never have worked. Defaults to `synthefy/nori-30m`; the retired slug is rejected with a pointed message. - The fixed 120s timeout sat below the measured cold start (125s on nori-30m). `request_timeout` is configurable, default 300s. - The multi-series guard tested duplicate timestamps in the test set only, so two series with disjoint timestamps trained as one. It now counts distinct tag combinations. Output points carry the source series tags. - Each bound of the read window applies on its own; `INTERVAL` string interpolation is gone, both bounds are query parameters. - The target is no longer aliased `AS y`: a source tag column of that name made the query unplannable. - A source tag named model/source/target is rejected: it would overwrite the provenance tags and make the skip_existing lookup self-contradictory. Security - The request body could redirect the outgoing request. `gateway_url` is no longer a parameter: it is a module constant with an operator-only NORI_GATEWAY_URL override, https-only outside loopback, parsed with urlsplit so userinfo cannot disguise the host. - Body keys are restricted to an explicit allowlist, and a trigger argument pins its value, so a body cannot move the write target via `measurement`. - Values beginning with `@` are refused: the config loader would otherwise evaluate them as dynaconf substitution tokens and hand the host's environment or filesystem back to the caller. - Caller-facing errors carry no internal detail; it goes to the log via PublicError.detail. Cost and resource control - skip_existing (default true) stops a schedule re-sending rows it has already paid for. - max_train_rows / max_predict_rows / predict_batch_size bound the payload, max_read_rows bounds the read as a SQL LIMIT. - A gateway fault part-way through keeps the batches already billed and reports `partial` with the shortfall. - The gateway call retries 408/409/425/429/5xx and connection errors, honouring Retry-After; read timeouts are not retried. Also - config_file_path TOML support via influxdata_plugin_utils, with a template. - Docstring metadata declares all 20 parameters for both trigger types. - README: 0 errors, 0 warnings (was 0/7). - test_nori_regression.py: 190 tests, no engine or network needed. - Dropped pandas and numpy; query rows deliver time as integer nanoseconds. - Rebased onto main. Co-Authored-By: Claude Opus 5 (1M context) --- influxdata/library/plugin_library.json | 6 +- influxdata/nori_regression/README.md | 461 ++++-- influxdata/nori_regression/manifest.toml | 4 +- influxdata/nori_regression/nori_regression.py | 1267 +++++++++++---- .../nori_regression_config_scheduler.toml | 56 + influxdata/nori_regression/requirements.txt | 3 +- .../nori_regression/test_nori_regression.py | 1426 +++++++++++++++++ 7 files changed, 2824 insertions(+), 399 deletions(-) create mode 100644 influxdata/nori_regression/nori_regression_config_scheduler.toml create mode 100644 influxdata/nori_regression/test_nori_regression.py diff --git a/influxdata/library/plugin_library.json b/influxdata/library/plugin_library.json index 2a44378..e7be840 100644 --- a/influxdata/library/plugin_library.json +++ b/influxdata/library/plugin_library.json @@ -343,12 +343,12 @@ { "name": "Nori Regression", "path": "influxdata/nori_regression/nori_regression.py", - "description": "[BETA] Requires InfluxDB 3.8.2+. Predict a numeric field in an InfluxDB 3 measurement from other columns with Nori, Synthefy's in-context-learning tabular regression model, via the Baseten gateway. Trains on the rows where the target field is present and predicts (imputes) the rows where it is null, writing the predictions back to InfluxDB. Scheduled and HTTP triggers; the model gateway slug is configurable (synthefy/nori or synthefy/nori-30m).", + "description": "[BETA] Requires InfluxDB 3.8.2+. Predicts a numeric field from other columns on the same rows with Nori, Synthefy's in-context tabular regression model, via the Synthefy inference gateway. Trains on the rows where the target is present and imputes the rows where it is null, writing predictions back to InfluxDB. Scheduled and HTTP triggers; the model slug is configurable (synthefy/nori-30m or synthefy/nori-6m).", "author": "Synthefy", "docs_file_link": "https://github.com/influxdata/influxdb3_plugins/blob/main/influxdata/nori_regression/README.md", "required_plugins": [], - "required_libraries": ["requests", "pandas", "numpy"], - "last_update": "2026-07-15", + "required_libraries": ["influxdata-plugin-utils>=0.2.0", "requests"], + "last_update": "2026-07-30", "trigger_types_supported": ["scheduler", "http"] }, { diff --git a/influxdata/nori_regression/README.md b/influxdata/nori_regression/README.md index b80845d..f39aed3 100644 --- a/influxdata/nori_regression/README.md +++ b/influxdata/nori_regression/README.md @@ -1,17 +1,20 @@ # Nori Regression Plugin -⚡ scheduled, http 🏷️ regression, tabular, machine-learning 🔧 InfluxDB 3 Core, InfluxDB 3 Enterprise +⚡ scheduled, http 🏷️ regression, tabular, machine-learning, imputation 🔧 InfluxDB 3 Core, InfluxDB 3 Enterprise -Predict a numeric field in an InfluxDB 3 measurement from other columns with **Nori**, Synthefy's -in-context-learning tabular regression model, called through the Synthefy inference gateway. The -plugin reads a window of rows, trains on the rows where the target field is present, predicts the -rows where it is null (imputation / backfill), and writes the predicted values back into InfluxDB. +> **Note:** This plugin requires InfluxDB 3.8.2 or later (it uses the synchronous write API). + +Predict a numeric field in an InfluxDB 3 measurement from other columns on the same rows with +**Nori**, Synthefy's in-context-learning tabular regression model, called through the Synthefy +inference gateway. The plugin reads a window of rows, trains on the rows where the target field is +present, predicts the rows where it is null (imputation / backfill), and writes the predicted values +back into InfluxDB. ## Description Nori is a tabular regression foundation model: you give it labeled feature rows (`X_train`, `y_train`) and query rows (`X_test`) in a single request, and it predicts a value for each query row -in one forward pass, with no training or fine-tuning. +in one forward pass, with no training or fine-tuning step. This plugin applies Nori to an InfluxDB measurement. You choose a target field and a set of feature columns; the plugin uses the rows where the target is present as the in-context training set and @@ -30,24 +33,33 @@ Key features: - **In-context tabular regression**: no training step; the recent labeled rows are the context. - **Imputation / backfill**: predicts the rows where the target is null and writes them back. -- **Scheduled or on-demand**: run on an interval (scheduled trigger) or via an HTTP endpoint. -- **Tag filtering**: operate on a single series selected by tags. -- **Writes results back** to InfluxDB as a new measurement using line protocol. +- **Scheduled or on-demand**: run on an interval, or call an HTTP endpoint with an explicit window. +- **Idempotent by default**: rows that already hold a prediction are skipped, so a repeating + schedule does not re-send and re-pay for the same rows. +- **Bounded cost**: row caps and a batch size keep one run's billed rows predictable. +- **Single-series guarantee**: a run that resolves to more than one series fails before it calls the + gateway, rather than training one model on two mixed series. ## Configuration +Plugin parameters may be given as key-value pairs in the `--trigger-arguments` flag of +`influxdb3 create trigger`, in the `trigger_arguments` field of the API, or entirely from a TOML +file via `config_file_path` — see [TOML configuration](#toml-configuration). For the HTTP trigger, a +documented subset may also be sent in the JSON request body. + ### Plugin metadata This plugin includes a JSON metadata schema in its docstring that declares the supported trigger -types (`scheduled`, `http`) and their arguments, so the InfluxDB 3 Explorer UI can render a -configuration form. +types (`scheduled`, `http`) and every parameter each accepts, so the +[InfluxDB 3 Explorer](https://docs.influxdata.com/influxdb3/explorer/) UI can render a configuration +form. ### Authentication for the Nori gateway The Nori gateway API key is a secret and is **never** read from trigger arguments or the request body (both are logged). It is resolved, in order: -1. an incoming `X-Nori-Api-Key: ` request header (HTTP trigger only), then +1. a non-empty `X-Nori-Api-Key: ` request header (HTTP trigger only), then 2. the `NORI_API_KEY` environment variable set on the InfluxDB host (required for the scheduled trigger). @@ -55,53 +67,114 @@ The key is intentionally **not** accepted in the `Authorization` header: InfluxD `Authorization` for its own request authorization, so a key placed there never reaches the plugin. Use the custom `X-Nori-Api-Key` header instead. -Get a Nori API key from the [Synthefy console](https://console.synthefy.com/). A single key works -for all Nori model variants (see [Supported models](#supported-models)), so you do not need a -separate key per variant. This plugin does not create keys. - -### Scheduled trigger parameters - -| Parameter | Default | Description | -|---|---|---| -| `measurement` | (required) | Source measurement (table) to read from. | -| `field` | (required) | The numeric field to predict (the regression target). The plugin trains on rows where it is present and predicts the rows where it is null. | -| `feature_fields` | (required) | Numeric feature columns (X) used to predict `field`, **space-separated** (e.g. `temp humidity`). Use spaces, not commas (`--trigger-arguments` splits argument pairs on commas) and not dots (field names may contain a `.`). In the HTTP JSON body it may also be a list. | -| `window` | `30d` | Time window of rows to read. Units: `s,min,h,d,w`. | -| `tags` | (none) | Filter to a single series. Format: `key:val key2:val2` (space-separated pairs). Required if the measurement holds more than one series (see [Notes](#notes)). | -| `model` | `synthefy/nori` | The Nori gateway slug to call. Your API key must be granted this slug. | -| `gateway_url` | `https://inference.baseten.co/predict` | Nori gateway endpoint. | -| `output_measurement` | `_regressed` | Measurement to write predictions to. | -| `target_database` | (trigger db) | Write predictions to a different database. | -| `dry_run` | `false` | If `true`, log predictions but do not write them. | -| `min_history` | `50` | Minimum labeled rows (target present) required to train; skip below this. | - -### HTTP trigger parameters - -All scheduled parameters are accepted (in the JSON request body or as trigger arguments), plus: - -| Parameter | Default | Description | -|---|---|---| -| `start_time` | (none) | ISO start of the window. Overrides `window`. | -| `end_time` | (none) | ISO end of the window. | +Get a Nori API key from the [Synthefy console](https://console.synthefy.com/). One key covers every +model slug its group is granted (see [Supported models](#supported-models)), so you do not normally +need a key per variant. This plugin does not create keys. + +The gateway endpoint itself is **not** a parameter: the request carries the operator's API key and +the training data, so a caller must never be able to choose its destination. An operator running a +private gateway can point the plugin at it with the `NORI_GATEWAY_URL` environment variable on the +InfluxDB host. It must be an `https://` URL; plain `http://` is accepted only for a loopback host +(`localhost`, `127.0.0.1` or `::1`), so a local mock gateway still works in testing. + +### Required parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `measurement` | string | required | Source measurement (table) to read from. | +| `field` | string | required | The numeric field to predict. The plugin trains on the rows where it is present and predicts the rows where it is null. | +| `feature_fields` | string | required | Numeric feature columns (X) used to predict `field`, **space-separated** (for example `temp humidity`). Use spaces, not commas (`--trigger-arguments` splits argument pairs on commas) and not dots (a field name may contain a `.`). | + +A column name that contains a space cannot be expressed in `feature_fields` as a trigger argument, +because every string form splits on whitespace. Name such a column from a TOML array +(`feature_fields = ["air temp", "humidity"]`) or from a JSON list in the HTTP body. + +### Optional parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `window` | string | `30d` | Time window of rows to read, ending at the trigger's call time. Units: `s`, `min`, `h`, `d`, `w`, with an integer magnitude. | +| `start_time` | string | *(none)* | ISO 8601 start of a fixed window. Given alone, the window ends now. | +| `end_time` | string | *(none)* | ISO 8601 end of a fixed window. Given alone, the window starts one `window` earlier. | +| `tags` | string | *(none)* | Filter to a single series. Format: `key:val key2:val2` (space-separated pairs, one value per key). A token without a `:` is rejected. Required when the window holds more than one series. | +| `model` | string | `synthefy/nori-30m` | The Nori gateway slug to call. See [Supported models](#supported-models). | +| `output_measurement` | string | `_regressed` | Measurement to write predictions to. Must differ from `measurement`. | +| `target_database` | string | *(trigger db)* | Write predictions to a different database. | +| `dry_run` | boolean | `false` | Log the first few predictions and return them all, without writing anything. | +| `skip_existing` | boolean | `true` | Skip rows that already hold a prediction in `output_measurement`. Set `false` to refresh earlier predictions with newer training data. | +| `min_history` | integer | `50` | Minimum labeled rows required to train; the run is skipped below this. | +| `max_train_rows` | integer | `1000` | Cap on labeled rows sent as the training context; the most recent rows are kept. This is the main cost control — the gateway bills per training row and column. | +| `max_predict_rows` | integer | `5000` | Cap on rows predicted per run; the most recent rows are kept and the rest wait for a later run. | +| `max_read_rows` | integer | `50000` | Ceiling on rows read from InfluxDB in one run, applied as a `LIMIT` on the query. The most recent rows are read, and a truncated read is logged with a warning. This bounds the plugin's memory: a row costs roughly 0.7 KB while it is held, so the default is about 35 MB. | +| `predict_batch_size` | integer | `1000` | Rows per gateway call. Each batch re-sends the training context and is billed separately, so a larger value costs less. | +| `request_timeout` | string | `300s` | Timeout for one gateway call. A cold start has been measured at 60-130 seconds, so keep this well above that. | +| `max_retries` | integer | `3` | Maximum attempts per gateway call and per write. `1` disables retry. | +| `config_file_path` | string | *(none)* | Path to a TOML file supplying every parameter, relative to `PLUGIN_DIR`. Cannot be combined with other inline arguments or a request body. | + +Two constraints are checked before anything runs: `min_history` must not exceed `max_train_rows` +(no run could otherwise ever qualify), and `output_measurement` must differ from `measurement`. All +of the integer parameters must be at least `1`. + +### HTTP request body parameters + +On the HTTP trigger, these keys may be sent in the JSON request body: + +`measurement`, `field`, `feature_fields`, `tags`, `window`, `start_time`, `end_time`, `dry_run`. + +`feature_fields` may be a JSON list (`{"feature_fields": ["temp", "humidity"]}`) or a +space-separated string, and `tags` may be a JSON object (`{"tags": {"site": "A"}}`). + +**A trigger argument pins its value.** The body may fill in what the trigger left open, but it +cannot change what the trigger already set — that is rejected. So an operator who wants the request +to choose the measurement creates the trigger without one, and an operator who wants it fixed sets +it as a trigger argument. This matters because `output_measurement` defaults to +`_regressed`: without the pin, a body-supplied `measurement` would move the write +target too. + +Every other parameter is **operator-only** and is rejected by name if it appears in the body. The +endpoint is reachable by anyone holding a database token, so the model slug (which selects a billed +model), the write targets (`output_measurement`, `target_database`), the row caps, the timeout and +`config_file_path` stay under the control of whoever created the trigger. + +`gateway_url` is not a parameter at all, in either place — use the `NORI_GATEWAY_URL` environment +variable. Passing it (or a parameter from the plugin's earlier forecasting revision: `mode`, +`horizon`, `step`, `lags`, `rolling`, `tz`) is rejected with a message naming the replacement, rather +than ignored. + +### TOML configuration + +Set the `PLUGIN_DIR` environment variable and reference the file with the `config_file_path` trigger +argument (relative paths resolve against `PLUGIN_DIR`, then `INFLUXDB3_PLUGIN_DIR`, then the parent +of `VIRTUAL_ENV`). The TOML file then supplies **all** parameters — it is mutually exclusive with +inline trigger arguments and with an HTTP request body. See +[`nori_regression_config_scheduler.toml`](nori_regression_config_scheduler.toml) for an annotated +template. -Scalar values in the JSON body override trigger arguments. A `tags` object may be sent as JSON -(`{"tags": {"site": "A"}}`), and `feature_fields` may be sent as a JSON list -(`{"feature_fields": ["temp", "humidity"]}`) or a space-separated string. +```bash +influxdb3 create trigger \ + --database mydb \ + --path "gh:influxdata/nori_regression/nori_regression.py" \ + --trigger-spec "every:1h" \ + --trigger-arguments config_file_path=nori_regression_config_scheduler.toml \ + nori_from_toml +``` ## Requirements -### Dependencies +### Software requirements -- `requests` -- `pandas` -- `numpy` +- **InfluxDB 3 Core or Enterprise**, version 3.8.2 or later, with the Processing Engine enabled + (`influxdb3 serve --plugin-dir /path/to/plugins`). +- **Python packages**: `influxdata-plugin-utils>=0.2.0`, `requests`. +- A **Nori API key** from the [Synthefy console](https://console.synthefy.com/), reachable from the + InfluxDB host over HTTPS. ### Installation steps 1. Install the Python dependencies into the InfluxDB 3 Processing Engine environment: ```bash - influxdb3 install package requests pandas numpy + influxdb3 install package influxdata-plugin-utils requests ``` 2. Reference the plugin directly from this repository with the `gh:` prefix (the form used in the @@ -109,19 +182,43 @@ Scalar values in the JSON body override trigger arguments. A `tags` object may b `nori_regression.py` into your plugin directory (the one passed to `influxdb3 serve --plugin-dir`) and use `--path nori_regression.py`. -3. Set the Nori gateway key on the InfluxDB host. Get your key from the - [Synthefy console](https://console.synthefy.com/) (one key works for all Nori variants): +3. Set the Nori gateway key on the InfluxDB host, so the scheduled trigger can read it: ```bash export NORI_API_KEY="" ``` -### Prerequisites +### Data requirements + +- The measurement holds at least `min_history` rows where the target `field` is present **and** + every `feature_fields` column is present. Those rows are the training context. +- It holds at least one row where the target is null and every feature is present. Those rows are + what gets predicted; if there are none, the run is a no-op. +- The window resolves to a **single series**. If the measurement holds several series (one per + `site`, say), pass a `tags` filter that isolates one, or create one trigger per series. +- The features actually explain the target. Nori sees no time and no row order, so a target that + depends on time rather than on the feature columns is not a good fit for this plugin. + +### Schema requirements + +The plugin reads `information_schema.columns` before it queries data, and fails with a message +naming the offending column if the schema cannot serve the request: + +| Column | Required type | +|---|---| +| `time` | timestamp (every InfluxDB measurement has one) | +| `field` (the target) | numeric field: `Int64`, `UInt64`, `Int32`, `Float64` or `Float32` | +| each `feature_fields` entry | numeric field, and neither the target nor `time` | +| each `tags` key | a tag column (`Dictionary(Int32, Utf8)`), not a field | +| the source tag columns | none named `model`, `source` or `target` | -- InfluxDB 3 Core or Enterprise (>= 3.8.2) with the Processing Engine enabled. -- A Nori API key from the [Synthefy console](https://console.synthefy.com/). -- A measurement with a numeric target field, one or more numeric feature columns, and enough labeled - rows (`>= min_history`) where the target is present. +A string or boolean column named as a feature is rejected here rather than coerced to null, which +would otherwise surface much later as `only 0 labeled rows`. + +The last row matters because every output point carries `model`, `source` and `target` tags for +provenance. A source tag with one of those names would overwrite the provenance on write *and* make +the `skip_existing` lookup contradict itself, so the run would silently re-send and re-pay for the +same rows on every tick. The plugin refuses the configuration instead. ## Trigger setup @@ -135,10 +232,13 @@ influxdb3 create trigger \ --database mydb \ --path "gh:influxdata/nori_regression/nori_regression.py" \ --trigger-spec "every:15m" \ - --trigger-arguments measurement=sensors,field=pressure,feature_fields="temp humidity",tags=site:A,model=synthefy/nori \ + --trigger-arguments measurement=sensors,field=pressure,feature_fields="temp humidity",tags=site:A,model=synthefy/nori-30m \ nori_sensors_pressure ``` +Because `skip_existing` defaults to `true`, each subsequent run only sends the rows that still have +no prediction. Once the window is fully imputed, the trigger stops calling the gateway entirely. + ### HTTP trigger ```bash @@ -151,7 +251,55 @@ influxdb3 create trigger \ ## Example usage -### On-demand HTTP regression +### Example 1: impute a missing field on a schedule + +Write sample data. The plugin needs at least `min_history` complete rows to train on, so this +example lowers that to `3` — a real deployment should leave it at the default and train on far more. +The last two rows carry `temp` and `humidity` but no `pressure`, and those are the ones that get +imputed: + +```bash +influxdb3 write --database mydb --precision s " +sensors,site=A temp=20.0,humidity=40.0,pressure=1000.0 1767225600 +sensors,site=A temp=22.0,humidity=41.0,pressure=1000.7 1767225660 +sensors,site=A temp=24.0,humidity=42.0,pressure=1001.4 1767225720 +sensors,site=A temp=25.0,humidity=45.0 1767229200 +sensors,site=A temp=21.0,humidity=41.0 1767229260 +" +``` + +```bash +influxdb3 create trigger \ + --database mydb \ + --path "gh:influxdata/nori_regression/nori_regression.py" \ + --trigger-spec "every:15m" \ + --trigger-arguments measurement=sensors,field=pressure,feature_fields="temp humidity",tags=site:A,min_history=3 \ + nori_example +``` + +Read the predictions back after the trigger runs: + +```bash +influxdb3 query --database mydb " +SELECT time, value, model, target, site +FROM sensors_regressed +ORDER BY time DESC +LIMIT 5 +" +``` + +**Expected output:** + +``` ++---------------------+--------+-------------------+----------+------+ +| time | value | model | target | site | ++---------------------+--------+-------------------+----------+------+ +| 2026-01-01T01:01:00 | 998.2 | synthefy/nori-30m | pressure | A | +| 2026-01-01T01:00:00 | 999.0 | synthefy/nori-30m | pressure | A | ++---------------------+--------+-------------------+----------+------+ +``` + +### Example 2: on-demand HTTP regression Call the HTTP endpoint (exposed at `/api/v3/engine/`), passing the Nori key in the header: @@ -168,9 +316,22 @@ curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ {"status": "success", "task_id": "...", "result": {"status": "success", "written": 24}} ``` -The predicted values are written to the `sensors_regressed` measurement. +A run that had nothing to do reports its real outcome instead of a bare success: + +```json +{"status": "skipped", "task_id": "...", "result": {"status": "skipped", "written": 0}} +``` + +A run stopped part-way by a gateway fault keeps the batches it already paid for and reports the +shortfall, so a caller never reads a partial result as a complete one: + +```json +{"status": "partial", "task_id": "...", "result": {"status": "partial", "written": 8, "remaining": 12}} +``` -### Backfill a specific window +The top-level `status` is one of `success`, `partial`, `skipped`, `dry_run` or `failed`. + +### Example 3: backfill a specific window ```bash curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ @@ -179,9 +340,10 @@ curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ -d '{"measurement":"sensors","field":"pressure","feature_fields":["temp","humidity"],"tags":{"site":"A"},"start_time":"2026-01-01T00:00:00Z","end_time":"2026-02-01T00:00:00Z"}' ``` -### Dry run (preview without writing) +Either bound may be given alone: `start_time` on its own reads up to now, and `end_time` on its own +reads the `window` before it. -Set `dry_run=true` to log the predictions without writing them back: +### Example 4: dry run (preview without writing) ```bash curl -X POST http://localhost:8181/api/v3/engine/nori_regress \ @@ -196,120 +358,193 @@ Each prediction is written as a point: - **Measurement:** `output_measurement` (default `_regressed`). - **Tags:** `model` (the slug), `source` (the input measurement), `target` (the predicted field), - and any single-value filter tags. + plus every tag of the source series (not only the tags you filtered on), so a point can always be + traced back to the series it was predicted for. - **Field:** `value` (float): the predicted target value. - **Timestamp:** the predicted row's own timestamp (nanoseconds). Example line protocol: ``` -sensors_regressed,model=synthefy/nori,source=sensors,target=pressure value=1001.2 1704672000000000000 +sensors_regressed,model=synthefy/nori-30m,source=sensors,target=pressure,site=A value=1001.2 1767225600000000000 ``` +## Cost and metering + +Every gateway call is a billed request, priced from the **training** rows and columns you send +(`max_train_rows` x the number of `feature_fields`), with a per-request floor. Three settings +control what a run costs: + +- `max_train_rows` bounds the priced rows in every call. +- `predict_batch_size` bounds the number of calls: each batch re-sends the same training context and + is billed again, so a larger batch size is cheaper. +- `skip_existing` (on by default) stops a repeating schedule from paying for rows it has already + predicted. With it off, an `every:15m` trigger over a 30-day window re-sends each row roughly + 2,880 times. + ## Querying predictions -```sql -SELECT time, value, model, target FROM sensors_regressed ORDER BY time DESC LIMIT 24; +```bash +influxdb3 query --database mydb " +SELECT date_trunc('hour', time) AS hour, count(*) AS predicted, avg(value) AS mean_value +FROM sensors_regressed +WHERE target = 'pressure' +GROUP BY 1 +ORDER BY 1 DESC +" ``` ## Notes - **What it predicts:** rows in the window where the target `field` is null but every `feature_fields` column is present. Rows where the target is already present become the training - set (`X_train`/`y_train`). If no rows are missing the target, the run is a no-op (`skipped`). It - does not overwrite existing target values. -- **One series per run:** predictions are written back at each row's own timestamp, so a run must - resolve to a single series. If the measurement holds several series (for example one per `site`) - and your `tags` filter does not isolate one, two rows can share a timestamp and their output - points would collide, so the plugin **fails with a clear message** rather than silently dropping - values. Add a `tags` filter, or run one trigger per series. + set. It never overwrites an existing target value. +- **One series per run:** the plugin counts the distinct tag combinations in the window and fails + before calling the gateway if there is more than one, because predictions are written back at each + row's own timestamp and two series would train as one model. - **Features only:** Nori sees just the columns you name in `feature_fields`. Row order does not matter, and no time-derived features are added. +- **Non-finite predictions:** the gateway returns JSON `null` for a row it cannot produce a finite + value for. Those rows are skipped and counted in the log; a batch that is entirely null fails + rather than reporting a successful run that wrote nothing. +- **A partial run keeps what it paid for, and says so:** if a later batch fails, the predictions the + earlier batches already returned are still written, because those batches were already billed. The + run reports `{"status": "partial", "written": N, "remaining": M}` rather than `success`, and the + remaining rows are picked up by the next run. ## Supported models The `model` argument is the Nori gateway slug your API key is granted: -- `synthefy/nori`: the base Nori in-context tabular regression model (default). -- `synthefy/nori-30m`: a 30M-parameter Nori variant. +| Slug | Parameters | Notes | +|---|---|---| +| `synthefy/nori-30m` | ~29M | The default, and the variant Synthefy's own documentation recommends. Priced higher and slower to cold-start (measured at ~125s). | +| `synthefy/nori-6m` | ~6M | Cheaper per request and faster to cold-start (measured at ~69s). | + +Which one predicts better depends on your data; try both with `dry_run=true` before committing a +schedule to one. -A single API key from the [Synthefy console](https://console.synthefy.com/) works for all of these -variants. +The bare `synthefy/nori` slug has been retired and no longer routes; the plugin rejects it with a +pointed message rather than letting the gateway answer `404`. One API key from the +[Synthefy console](https://console.synthefy.com/) works for every slug it is granted. ## Code overview ### Files - `nori_regression.py`: the plugin (metadata docstring and implementation). +- `nori_regression_config_scheduler.toml`: annotated TOML configuration template. +- `test_nori_regression.py`: unit tests (`pytest influxdata/nori_regression/`); no engine or + network needed. - `requirements.txt`: Python dependencies. - `manifest.toml`: packaging metadata. ### Key functions -- `process_scheduled_call(influxdb3_local, call_time, args)`: scheduled entry point. -- `process_request(influxdb3_local, query_parameters, request_headers, request_body, args)`: HTTP entry point. -- `_query(influxdb3_local, cfg, task_id)`: reads the target and feature columns for the window into a DataFrame. -- `_regress(influxdb3_local, cfg, api_key, task_id)`: splits labeled and null-target rows and builds `X_train`/`y_train`/`X_test`. -- `_call_nori(...)`: sends the in-context regression request (`task="regression"`) to the gateway. -- `_write_predictions(...)`: writes the predictions back with `write_sync`. +- `process_scheduled_call(influxdb3_local, call_time, args)`: scheduled entry point; anchors the + window to `call_time`. +- `process_request(influxdb3_local, query_parameters, request_headers, request_body, args)`: HTTP + entry point; applies the request-body allowlist. +- `_load_config(args, body)`: merges trigger arguments, the TOML file and the allowlisted body keys, + then validates them. +- `_resolve_schema(influxdb3_local, cfg)`: reads column names *and* types, rejecting a + non-numeric target or feature. +- `_resolve_window(cfg, now)`: resolves `window` / `start_time` / `end_time` into one range, + honouring each bound on its own. +- `_regress(...)`: enforces the single-series rule, splits labeled from null-target rows, applies + the caps and `skip_existing`, and batches the gateway calls. +- `_call_nori(...)`: sends the in-context regression request and validates the response. +- `_write_predictions(...)`: writes the predictions with `write_sync` so a write error surfaces + during trigger execution. ## Troubleshooting ### Common issues -### Missing API key +Each heading below quotes the text the plugin actually logs or returns, so a message can be +searched for directly. Every failure is logged with a `task_id`; use it to correlate the +caller-facing message with the full detail in `processing_engine_logs`. + +#### Missing API key The plugin cannot find a Nori gateway key. **Solution:** set `NORI_API_KEY` on the InfluxDB host, or pass an `X-Nori-Api-Key: ` header -when calling the HTTP trigger (see [Authentication](#authentication-for-the-nori-gateway)). +when calling the HTTP trigger (see +[Authentication](#authentication-for-the-nori-gateway)). An empty header value is ignored and the +environment variable is used instead. + +#### Gateway returns 403 or 404 + +- **`HTTP 403 ... please check the api-key you provided`:** the key is wrong, revoked, or malformed. +- **`HTTP 404 ... please check the model you provided`:** the `model` slug does not exist or your + key's group was not granted it. Confirm the spelling against + [Supported models](#supported-models). + +**Solution:** re-copy the key from the [Synthefy console](https://console.synthefy.com/) and check +the slug. Neither status is retried, because neither is transient. + +#### Request body may not set ... + +The HTTP request body contained an operator-only parameter (for example `model` or +`target_database`). + +**Solution:** set it as a trigger argument or in the TOML config file. Only the query-shape keys +listed in [HTTP request body parameters](#http-request-body-parameters) may come from the body. -### Model API not found (404) +#### `gateway_url` is not a parameter -The gateway rejects the `model` slug for your key. +The endpoint moved out of the configuration entirely, because the request carries the Nori API key. -**Solution:** confirm the `model` slug is spelled correctly (for example `synthefy/nori` or -`synthefy/nori-30m`) and that you passed a valid API key from the -[Synthefy console](https://console.synthefy.com/). +**Solution:** set `NORI_GATEWAY_URL` on the InfluxDB host. It must be an `https://` URL. -### Not enough labeled rows, or nothing to predict +#### Not enough labeled rows, or nothing to predict -- **`only N labeled rows (< min_history)`:** fewer than `min_history` rows have the target present. - Widen `window`, lower `min_history`, or check that `measurement`/`field`/`feature_fields`/`tags` - select data. +- **`only N labeled rows (< min_history)`:** fewer than `min_history` rows have both the target and + every feature present. Widen `window`, lower `min_history`, or check that + `measurement`/`field`/`feature_fields`/`tags` select the data you expect. - **`no rows to predict`:** every target value in the window is already present. The plugin only - fills rows where the target is null; there is nothing to impute. + fills rows where the target is null. +- **`every row in the window already holds a prediction`:** `skip_existing` did its job. Set + `skip_existing=false` to recompute them with newer training data. -### Multiple series sharing timestamps +#### The window holds N series -The measurement holds more than one series and your `tags` filter did not isolate one, so -predictions would collide on write. +The measurement holds more than one series and your `tags` filter did not isolate one, so a single +model would be trained on mixed series. -**Solution:** add a `tags` filter that selects a single series, or run one trigger per series. +**Solution:** add a `tags` filter that selects one series, or create one trigger per series. The +error message lists the first few series it found. -### Feature field not found +#### Feature or target column rejected -A `feature_fields` column does not exist in the measurement, or it clashes with the target field or -the reserved names `time`/`y` (not allowed). +A column does not exist, is not a numeric field, or clashes with the target field or the reserved +names `time`/`y`. -**Solution:** fix the column names in `feature_fields`. +**Solution:** fix the column names, and check the types with +`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'sensors'`. -### Cold-start latency on the first call +#### Cold-start latency and timeouts -If the model has scaled to zero, the first request after idle can be slow (about 60-70 seconds) or -return a 500 once. +The models scale to zero, so the first request after an idle period is slow: about 69 seconds for +`synthefy/nori-6m` and 125 seconds for `synthefy/nori-30m` in measurement, and it can return a +`503` or a non-JSON body from the fronting proxy once. -**Solution:** retry; the scheduled trigger recovers on the next tick. Pre-warm with a direct call -before time-sensitive use. +**Solution:** the default `request_timeout` of `300s` and `max_retries` of `3` are set to absorb +this; a `503`, a `429` and a connection error are retried with backoff. A read timeout is **not** +retried — it has already spent the whole budget, and it usually means the key's group was never +granted the slug. Raise `request_timeout` only if you see genuine timeouts on a warm model. ## Limitations -- One series per run; run multiple triggers for multiple series (the plugin fails loud if a run - resolves to more than one series). Multi-series imputation in a single run is a possible - enhancement. -- Imputes only rows where the target is null; it does not overwrite existing values. -- Prediction quality depends on how well `feature_fields` explain the target. -- Each call is a billed gateway request; a larger `window` sends more rows and tokens. +- One series per run; create one trigger per series for a multi-series measurement. Multi-series + imputation in a single run is a possible enhancement. +- Imputes only rows where the target is null; it never overwrites an existing value. +- Prediction quality depends on how well `feature_fields` explain the target. Nori adds no + time-derived features, so this plugin is not a time-series forecaster. +- A feature column whose name contains a space is only reachable via a TOML array or the HTTP JSON + body, not via `--trigger-arguments`. +- Each gateway call is billed; see [Cost and metering](#cost-and-metering). ## License diff --git a/influxdata/nori_regression/manifest.toml b/influxdata/nori_regression/manifest.toml index a0b795d..52a9fc1 100644 --- a/influxdata/nori_regression/manifest.toml +++ b/influxdata/nori_regression/manifest.toml @@ -3,7 +3,7 @@ manifest_schema_version = "1.2" [plugin] name = "nori_regression" version = "0.1.0" -description = "Predict a numeric InfluxDB 3 field from other columns with Synthefy's Nori in-context tabular regression model (via the Baseten gateway). Imputes rows where the target field is null." +description = "Predict a numeric InfluxDB 3 field from other columns with Synthefy's Nori in-context tabular regression model, via the Synthefy inference gateway. Imputes rows where the target field is null." triggers = ["process_scheduled_call", "process_request"] homepage = "https://www.influxdata.com/" repository = "https://github.com/influxdata/influxdb3_plugins/tree/main/influxdata/nori_regression" @@ -16,4 +16,4 @@ exclude = [ [dependencies] database_version = ">=3.8.2" -python = ["requests", "pandas", "numpy"] +python = ["influxdata-plugin-utils>=0.2.0", "requests"] diff --git a/influxdata/nori_regression/nori_regression.py b/influxdata/nori_regression/nori_regression.py index 7ad3a1d..4eec194 100644 --- a/influxdata/nori_regression/nori_regression.py +++ b/influxdata/nori_regression/nori_regression.py @@ -4,394 +4,1103 @@ "scheduled_args_config": [ {"name": "measurement", "example": "sensors", "description": "Source measurement (table) to read from.", "required": true}, {"name": "field", "example": "pressure", "description": "The numeric field to predict (the regression target y). The plugin trains on rows where this field is present and predicts the rows where it is null.", "required": true}, - {"name": "feature_fields", "example": "temp humidity", "description": "Numeric feature columns (X) used to predict `field`, separated by spaces (e.g. 'temp humidity'). Use spaces, not commas: --trigger-arguments splits argument pairs on commas. In the HTTP JSON body this may also be a list. Required.", "required": true}, - {"name": "window", "example": "30d", "description": "Time window of rows to read from InfluxDB. Units: s,min,h,d,w.", "required": false}, - {"name": "tags", "example": "site:A", "description": "Filter to a single series. Format: key:val key2:val2 (space-separated pairs, single value per key). Required if the measurement holds more than one series.", "required": false}, - {"name": "model", "example": "synthefy/nori", "description": "The Nori gateway slug to call. Available slugs: synthefy/nori (default) and synthefy/nori-30m (a 30M-parameter variant). Your API key must be granted this slug.", "required": false}, - {"name": "gateway_url", "example": "https://inference.baseten.co/predict", "description": "Nori gateway endpoint.", "required": false}, + {"name": "feature_fields", "example": "temp humidity", "description": "Numeric feature columns (X) used to predict `field`, separated by spaces (e.g. 'temp humidity'). Use spaces, not commas: --trigger-arguments splits argument pairs on commas. A field name containing a space is only reachable via a TOML config file or the HTTP JSON body, where this may be a list.", "required": true}, + {"name": "window", "example": "30d", "description": "Time window of rows to read from InfluxDB, ending at the trigger's call time. Units: s,min,h,d,w (integer magnitude).", "required": false}, + {"name": "start_time", "example": "2026-01-01T00:00:00Z", "description": "ISO start of a fixed window instead of a trailing one. With skip_existing left on, a schedule over a fixed window backfills and then stops calling the gateway.", "required": false}, + {"name": "end_time", "example": "2026-02-01T00:00:00Z", "description": "ISO end of a fixed window. Given alone, the window starts one `window` earlier.", "required": false}, + {"name": "tags", "example": "site:A", "description": "Filter to a single series. Format: key:val key2:val2 (space-separated pairs, single value per key). A token without ':' is rejected. Required if the window holds more than one series.", "required": false}, + {"name": "model", "example": "synthefy/nori-30m", "description": "The Nori gateway slug to call. Current slugs: synthefy/nori-30m (default, ~29M params) and synthefy/nori-6m (cheaper and faster to cold-start). The bare 'synthefy/nori' slug is retired. Your API key must be granted the slug.", "required": false}, {"name": "output_measurement", "example": "sensors_regressed", "description": "Where to write predictions. Default: _regressed.", "required": false}, {"name": "target_database", "example": "predictions", "description": "Write predictions to this database instead of the trigger's own.", "required": false}, {"name": "dry_run", "example": "false", "description": "If true, log predictions but do not write them.", "required": false}, - {"name": "min_history", "example": "50", "description": "Minimum labeled rows (target present) required to train; abort below this.", "required": false} + {"name": "min_history", "example": "50", "description": "Minimum labeled rows (target present) required to train; abort below this.", "required": false}, + {"name": "max_train_rows", "example": "1000", "description": "Cap on labeled rows sent as the in-context training set; the most recent rows are kept. Each row is billed by the gateway.", "required": false}, + {"name": "max_predict_rows", "example": "5000", "description": "Cap on rows predicted per run; the most recent rows are kept.", "required": false}, + {"name": "max_read_rows", "example": "50000", "description": "Ceiling on rows read from InfluxDB in one run, as a LIMIT on the query. The most recent rows are read and a truncated read is logged. Guards against a very wide window.", "required": false}, + {"name": "predict_batch_size", "example": "1000", "description": "Rows per gateway call. Every batch re-sends the training context and is billed separately, so a larger value costs less.", "required": false}, + {"name": "request_timeout", "example": "300s", "description": "Timeout for one gateway call. A cold start can take 60-130s, so keep this well above that.", "required": false}, + {"name": "max_retries", "example": "3", "description": "Maximum attempts per gateway call and per write (1 disables retry).", "required": false}, + {"name": "skip_existing", "example": "true", "description": "Skip rows that already hold a prediction in output_measurement, so a repeating schedule does not re-send and re-bill the same rows. Set false to refresh earlier predictions with newer training data.", "required": false}, + {"name": "config_file_path", "example": "nori_regression_config_scheduler.toml", "description": "Path to a TOML file supplying all parameters, relative to PLUGIN_DIR. Mutually exclusive with inline trigger arguments.", "required": false} ], "http_args_config": [ {"name": "measurement", "example": "sensors", "description": "Source measurement. May also be provided in the JSON request body.", "required": false}, {"name": "field", "example": "pressure", "description": "Target field to predict. May also be in the request body.", "required": false}, - {"name": "feature_fields", "example": "temp humidity", "description": "Feature columns. In the JSON request body this may be a JSON list of column names, or a space-separated string.", "required": false}, - {"name": "start_time", "example": "2026-01-01T00:00:00Z", "description": "Optional ISO start of the window (overrides `window`).", "required": false}, - {"name": "end_time", "example": "2026-02-01T00:00:00Z", "description": "Optional ISO end of the window.", "required": false} + {"name": "feature_fields", "example": "temp humidity", "description": "Feature columns. May also be in the request body, as a JSON list of column names or a space-separated string.", "required": false}, + {"name": "tags", "example": "site:A", "description": "Filter to a single series, as space-separated key:val pairs. May also be in the request body as a JSON object.", "required": false}, + {"name": "window", "example": "30d", "description": "Time window of rows to read, ending now. May also be in the request body. Ignored when start_time and end_time are both given.", "required": false}, + {"name": "start_time", "example": "2026-01-01T00:00:00Z", "description": "ISO start of the window. May also be in the request body. Given alone, the window ends now.", "required": false}, + {"name": "end_time", "example": "2026-02-01T00:00:00Z", "description": "ISO end of the window. May also be in the request body. Given alone, the window starts one `window` earlier.", "required": false}, + {"name": "dry_run", "example": "false", "description": "If true, log predictions but do not write them. May also be in the request body.", "required": false}, + {"name": "model", "example": "synthefy/nori-30m", "description": "The Nori gateway slug to call. Trigger argument only: it selects a billed model, so the request body cannot override it.", "required": false}, + {"name": "output_measurement", "example": "sensors_regressed", "description": "Where to write predictions. Trigger argument only: the request body cannot override a write target.", "required": false}, + {"name": "target_database", "example": "predictions", "description": "Write predictions to this database instead of the trigger's own. Trigger argument only: the request body cannot override a write target.", "required": false}, + {"name": "min_history", "example": "50", "description": "Minimum labeled rows required to train. Trigger argument only.", "required": false}, + {"name": "max_train_rows", "example": "1000", "description": "Cap on labeled rows sent as the training set. Trigger argument only: it bounds billed rows.", "required": false}, + {"name": "max_predict_rows", "example": "5000", "description": "Cap on rows predicted per request. Trigger argument only: it bounds billed rows.", "required": false}, + {"name": "max_read_rows", "example": "50000", "description": "Ceiling on rows read from InfluxDB in one run. Trigger argument only: it bounds the work a request can ask the host to do.", "required": false}, + {"name": "predict_batch_size", "example": "1000", "description": "Rows per gateway call. Trigger argument only: it bounds billed calls.", "required": false}, + {"name": "request_timeout", "example": "300s", "description": "Timeout for one gateway call. Trigger argument only.", "required": false}, + {"name": "max_retries", "example": "3", "description": "Maximum attempts per gateway call and per write. Trigger argument only.", "required": false}, + {"name": "skip_existing", "example": "true", "description": "Skip rows that already hold a prediction. Trigger argument only.", "required": false}, + {"name": "config_file_path", "example": "nori_regression_config_scheduler.toml", "description": "Path to a TOML file supplying all parameters, relative to PLUGIN_DIR. Trigger argument only: the request body cannot name a file to read. Mutually exclusive with a request body.", "required": false} ] } """ import json +import math import os import random -import re import time import uuid -from datetime import timedelta +from datetime import datetime, timezone +from urllib.parse import urlsplit -import numpy as np -import pandas as pd import requests +from influxdata_plugin_utils.config import Validator, load_plugin_config +from influxdata_plugin_utils.parsing import ( + parse_bool, + parse_delimited_list, + parse_key_value, + parse_timedelta, +) +from influxdata_plugin_utils.write import write_data -# --- Nori gateway defaults ------------------------------------------------- -# The gateway routes by the `model` slug in the body; the OUTGOING request to the gateway -# authenticates with an `Authorization: Api-Key ` header (see _call_nori). The key is a -# SECRET: the plugin reads it from the NORI_API_KEY environment variable on the InfluxDB host, or -# (HTTP trigger only) from an incoming `X-Nori-Api-Key` request header. It is NEVER read from -# trigger args or the request body (which get logged), and never from the incoming `Authorization` -# header (InfluxDB consumes that header for its own request authorization). +# Note: LineBuilder is provided by the InfluxDB 3 plugin framework at runtime. + +# --- Nori gateway ---------------------------------------------------------- +# The gateway routes by the `model` slug in the request body; the OUTGOING request authenticates +# with an `Authorization: Api-Key ` header (see _call_nori). +# +# The endpoint is a module constant, not a parameter: it receives the operator's API key and the +# training data, so a caller must never be able to choose it. An operator running a private gateway +# can override it with the NORI_GATEWAY_URL environment variable on the InfluxDB host, which is +# operator-controlled in the same way the key is. DEFAULT_GATEWAY_URL = "https://inference.baseten.co/predict" -DEFAULT_MODEL_SLUG = "synthefy/nori" +GATEWAY_URL_ENV_VAR = "NORI_GATEWAY_URL" + +# The key is a SECRET: it is read from the NORI_API_KEY environment variable on the InfluxDB host, +# or (HTTP trigger only) from an incoming X-Nori-Api-Key request header. It is NEVER read from +# trigger args or the request body (both are logged), and never from the incoming `Authorization` +# header (InfluxDB consumes that header for its own request authorization). API_KEY_ENV_VAR = "NORI_API_KEY" +API_KEY_HEADER = "X-Nori-Api-Key" -_UNIT_SECONDS = {"s": 1, "min": 60, "h": 3600, "d": 86400, "w": 604800} +# Slugs name their parameter count so the model behind a slug never silently changes. The bare +# `synthefy/nori` slug was retired and now returns 404, so it is rejected with a pointed message. +DEFAULT_MODEL_SLUG = "synthefy/nori-30m" +RETIRED_MODEL_SLUGS = {"synthefy/nori"} +# Gateway faults worth another attempt: 408/409/425 (transient conflicts), 429 (per-key rate limit, +# 50/min) and every 5xx (a cold start can 503, or 500 once). A 4xx outside that set is a permanent +# input, key or slug problem, so retrying it only wastes the caller's time and the gateway's quota. +RETRYABLE_STATUS = frozenset({408, 409, 425, 429}) | frozenset(range(500, 600)) +MAX_BACKOFF_SECONDS = 30.0 -def _interval_to_timedelta(text: str) -> timedelta: - """Parse '30d' / '15min' / '1h' into a timedelta (also used to validate the `window` arg).""" - text = str(text).strip() - num, unit = "", "" - for ch in text: - (num := num + ch) if (ch.isdigit() or ch == ".") else (unit := unit + ch) - if not num: - raise ValueError(f"interval {text!r} has no number (expected e.g. '30d', '15min', '1h')") - if unit not in _UNIT_SECONDS: - raise ValueError(f"bad interval unit in {text!r} (use s/min/h/d/w)") - return timedelta(seconds=float(num) * _UNIT_SECONDS[unit]) +# Request bodies reach this plugin from anyone holding a database token, so only these keys may be +# taken from the body. Everything else (the model slug, the write target, the row caps, the TOML +# path) stays under the operator's control via trigger arguments or the TOML file. +BODY_OVERRIDABLE_KEYS = frozenset( + { + "measurement", + "field", + "feature_fields", + "tags", + "window", + "start_time", + "end_time", + "dry_run", + } +) +MAX_BODY_BYTES = 10 * 1024 * 1024 -def _ident(name: str) -> str: - """Quote a SQL identifier, escaping embedded double-quotes. +# dynaconf (under influxdata_plugin_utils.config) evaluates converter tokens on any string value +# that STARTS WITH "@": @format and @jinja interpolate `env`, @read_file reads the host filesystem, +# @get reads other settings. Every one of dynaconf's ~30 tokens begins with this single character, +# and nothing else triggers them (a leading space or a doubled @ is inert), so refusing a leading +# "@" is a complete guard. Without it a request body of {"measurement": "@format {env[NORI_API_KEY]}"} +# comes back resolved in the error message - the plugin's own secret, handed to the caller. +DYNACONF_TOKEN_PREFIX = "@" - Identifiers (measurement / field / tag-key names) cannot be passed as query parameters, so - they are interpolated, but quoted and escaped so a name containing a quote can neither break - the query nor inject SQL. Tag/time *values* are passed as bound parameters (see _where_clause). +# Parameters an earlier revision accepted. They are rejected by name rather than ignored, so an +# operator following the old documentation gets a message instead of silence. +REMOVED_PARAMETERS = { + "gateway_url": ( + "the endpoint is no longer a parameter, because the request carries the Nori API key. " + f"Set the {GATEWAY_URL_ENV_VAR} environment variable on the InfluxDB host instead." + ), + "mode": "this plugin only does regression; the forecast mode was removed.", + "horizon": "forecasting was removed; this plugin imputes rows where the target is null.", + "step": "forecasting was removed; this plugin imputes rows where the target is null.", + "lags": "forecasting was removed; Nori sees only the columns named in feature_fields.", + "rolling": "forecasting was removed; Nori sees only the columns named in feature_fields.", + "tz": "no time-derived features are computed, so no timezone is needed.", +} + +# Tags every output point carries so a prediction can be traced to what produced it. +PROVENANCE_TAGS = frozenset({"model", "source", "target"}) + +# information_schema data types (mirror influxdata_plugin_utils.introspection). +TAG_DATA_TYPE = "Dictionary(Int32, Utf8)" +NUMERIC_TYPES = frozenset({"Int64", "UInt64", "Float64", "Int32", "Float32"}) + + +class PublicError(Exception): + """An error whose message is written for the caller and is safe to return over HTTP. + + `detail` carries anything that must NOT be returned - a host file path, a parser's internal + text, a body echoed by a private gateway. The entry points log `log_text()` and return only + `str(e)`. """ - return '"' + str(name).replace('"', '""') + '"' + def __init__(self, message: str, detail: str = ""): + super().__init__(message) + self.detail = detail -def _get_api_key(request_headers=None) -> str: - """Resolve the gateway key: the incoming `X-Nori-Api-Key` header wins (HTTP trigger), else the - NORI_API_KEY env var. + def log_text(self) -> str: + return f"{self}" + (f" [detail: {self.detail}]" if self.detail else "") - The key is deliberately NOT read from the incoming `Authorization` header: InfluxDB parses that - header for its own request authorization, so a custom scheme there does not reach the plugin. + +def _log_text(e: Exception) -> str: + """The full text for the log: a PublicError's detail included, anything else as-is.""" + return e.log_text() if isinstance(e, PublicError) else str(e) + + +class ConfigError(PublicError): + """A problem with the configuration, named so the caller can fix their own input.""" + + +class GatewayError(PublicError): + """A Nori gateway fault. The message names the status and the model slug, never the endpoint or + the gateway's echoed body.""" + + +# --- Configuration --------------------------------------------------------- + +VALIDATORS: list = [ + # No must_exist: a missing required value is reported by _normalize_config, which names the + # parameter and what it is for instead of dynaconf's "... is required in env main". + Validator("measurement", default="", cast=str), + Validator("field", default="", cast=str), + Validator("feature_fields", default="", cast=parse_delimited_list), + Validator("window", default="30d", cast=parse_timedelta), + Validator("model", default=DEFAULT_MODEL_SLUG, cast=str), + Validator("dry_run", default=False, cast=parse_bool), + Validator("skip_existing", default=True, cast=parse_bool), + Validator("min_history", default=50, gte=1, cast=int), + Validator("max_train_rows", default=1000, gte=1, cast=int), + Validator("max_predict_rows", default=5000, gte=1, cast=int), + Validator("max_read_rows", default=50_000, gte=1, cast=int), + Validator("predict_batch_size", default=1000, gte=1, cast=int), + Validator("max_retries", default=3, gte=1, cast=int), + Validator("request_timeout", default="300s", cast=parse_timedelta), +] + + +def _reject_dynaconf_tokens(values, where: str, path: str = "") -> None: + """Refuse a value that dynaconf would evaluate as a converter token. + + Applied to trigger arguments and to request-body values before they reach load_plugin_config. + A TOML file is not checked: it is authored by the operator on the host, at the same trust level + as the environment those tokens would read. """ - if request_headers: - for k, v in request_headers.items(): - if k.lower() == "x-nori-api-key": # case-insensitive per server - return v.strip() - key = os.environ.get(API_KEY_ENV_VAR, "").strip() - if not key: - raise ValueError( - f"No Nori API key. Set the {API_KEY_ENV_VAR} env var on the InfluxDB host, " - f"or pass an 'X-Nori-Api-Key: ' header (HTTP trigger)." + if isinstance(values, dict): + for key, value in values.items(): + _reject_dynaconf_tokens(value, where, f"{path}.{key}" if path else str(key)) + return + if isinstance(values, (list, tuple)): + for i, value in enumerate(values): + _reject_dynaconf_tokens(value, where, f"{path}[{i}]") + return + if isinstance(values, str) and values.startswith(DYNACONF_TOKEN_PREFIX): + raise ConfigError( + f"{where} value for {path!r} may not begin with " + f"{DYNACONF_TOKEN_PREFIX!r}: the configuration loader would evaluate it as a " + f"substitution token and read the InfluxDB host's environment or filesystem." ) - return key -def _split_list(text) -> list: - """Parse a space- (or comma-) separated arg into a list of trimmed, non-empty strings. +def _load_config(args: dict | None, body: dict | None = None) -> dict: + """Build the typed config from trigger args, an optional TOML file and an optional HTTP body. - NOT '.'-separated: InfluxDB field names may contain dots. Prefer spaces in `--trigger-arguments` - (the CLI splits argument pairs on commas, so a comma inside a value breaks parsing); commas are - fine inside the HTTP JSON body. + A TOML file (`config_file_path`, trigger arguments only) supplies every parameter and is + mutually exclusive with inline arguments and with a request body, matching the other plugins in + this repository. Body keys are restricted to BODY_OVERRIDABLE_KEYS; any other key is rejected + by name rather than dropped in silence. """ - return [x for x in re.split(r"[,\s]+", str(text).strip()) if x] - - -def _build_config(args: dict) -> dict: - """Parse trigger args (strings) into a typed config with defaults.""" - args = args or {} - measurement = args.get("measurement") - field = args.get("field") - tags = {} - if args.get("tags"): - for pair in args["tags"].split(): # space-separated key:val pairs (a value may contain '.') - if ":" in pair: - k, v = pair.split(":", 1) - tags[k.strip()] = v.strip() + args = dict(args or {}) + body = dict(body or {}) + + _reject_dynaconf_tokens(args, "trigger argument") + _reject_dynaconf_tokens(body, "request body") + + removed = sorted(set(REMOVED_PARAMETERS) & {*args, *body}) + if removed: + raise ConfigError( + "; ".join(f"`{key}` is not a parameter: {REMOVED_PARAMETERS[key]}" for key in removed) + ) + + if args.get("config_file_path"): + inline = sorted(set(args) - {"config_file_path"}) + if inline: + raise ConfigError( + f"config_file_path supplies every parameter, so it cannot be combined with the " + f"inline trigger argument(s) {inline}. Move them into the TOML file, or drop " + f"config_file_path." + ) + if body: + raise ConfigError( + "config_file_path supplies every parameter, so the request body must be empty. " + "Remove config_file_path from the trigger arguments to configure per request." + ) + + if body: + rejected = sorted(k for k in body if k not in BODY_OVERRIDABLE_KEYS) + if rejected: + raise ConfigError( + f"request body may not set {rejected}: these are operator settings. " + f"Set them as trigger arguments (or in the TOML config file) instead. " + f"Body-overridable keys: {sorted(BODY_OVERRIDABLE_KEYS)}." + ) + # A trigger argument PINS its value: the body may fill in what the operator left open, but + # not move what the operator chose. Otherwise a body-settable `measurement` re-points the + # read, and with it the `_regressed` write target the operator expected. + pinned = sorted(k for k in body if k in args) + if pinned: + raise ConfigError( + f"request body may not override {pinned}: the trigger already fixes " + f"{'them' if len(pinned) > 1 else 'it'}. Create a trigger without " + f"{'those arguments' if len(pinned) > 1 else 'that argument'} to set " + f"{'them' if len(pinned) > 1 else 'it'} per request." + ) + # An explicit JSON null means "unset": drop those keys so the validator default applies. + args.update({k: v for k, v in body.items() if v is not None}) + + toml_path = args.get("config_file_path") + try: + settings = load_plugin_config( + args, validators=VALIDATORS, source="toml" if toml_path else "args" + ) + except Exception as e: + if toml_path: + # The resolved path and the parser's text are operator-side detail, and an HTTP caller + # reaches this by posting an empty body to a TOML-configured trigger. + raise ConfigError( + "the trigger's TOML configuration could not be loaded; see the plugin logs for " + "this task_id", + detail=f"{toml_path}: {type(e).__name__}: {e}", + ) from e + raise ConfigError(f"invalid configuration: {e}") from e + + return _normalize_config(settings) + + +def _normalize_config(cfg) -> dict: + """Validate cross-field constraints and return a plain dict.""" + measurement = str(cfg.get("measurement") or "").strip() + field = str(cfg.get("field") or "").strip() + if not measurement: + raise ConfigError("`measurement` is required: the source measurement (table) to read") + if not field: + raise ConfigError("`field` is required: the numeric field to predict") + + # dict.fromkeys de-duplicates while preserving order: a repeated column would otherwise + # produce a duplicate SQL projection and a duplicated feature in every row. + feature_fields = list(dict.fromkeys(cfg.get("feature_fields") or [])) + if not feature_fields: + raise ConfigError( + "`feature_fields` is required: the space-separated numeric feature columns (X) " + "used to predict `field`" + ) + reserved = [f for f in feature_fields if f == field or f == "time"] + if reserved: + raise ConfigError( + f"feature_fields cannot include the target field or 'time': {reserved}" + ) + + model = str(cfg.get("model") or DEFAULT_MODEL_SLUG).strip() + if model in RETIRED_MODEL_SLUGS: + raise ConfigError( + f"model slug {model!r} is retired and no longer routes. Use 'synthefy/nori-30m' " + f"(~29M parameters) or 'synthefy/nori-6m' (cheaper, faster cold start)." + ) + + try: + tags = parse_key_value(cfg.get("tags") or {}, pair_sep=" ", kv_sep=":") + except ValueError as e: + raise ConfigError( + f"invalid `tags`: {e}. Format: 'key:val key2:val2' (space-separated pairs)." + ) from e + + request_timeout = cfg.request_timeout.total_seconds() + if request_timeout <= 0: + raise ConfigError("`request_timeout` must be positive") + + if cfg.min_history > cfg.max_train_rows: + raise ConfigError( + f"min_history ({cfg.min_history}) exceeds max_train_rows ({cfg.max_train_rows}), " + f"so no run can ever qualify" + ) + + output_measurement = str(cfg.get("output_measurement") or "").strip() or ( + f"{measurement}_regressed" + ) + if output_measurement == measurement: + raise ConfigError( + "output_measurement must differ from measurement: the plugin would otherwise write " + "predictions into the column it reads as ground truth" + ) + return { "measurement": measurement, "field": field, - # feature columns (X); space-separated (see _split_list). dict.fromkeys de-duplicates while - # preserving order (a repeated column would otherwise produce a duplicate SQL projection). - "feature_fields": list(dict.fromkeys(_split_list(args.get("feature_fields", "")))), + "feature_fields": feature_fields, "tags": tags, - "window": args.get("window", "30d"), - "model": args.get("model", DEFAULT_MODEL_SLUG), - "gateway_url": args.get("gateway_url", DEFAULT_GATEWAY_URL), - "output_measurement": args.get("output_measurement") or (f"{measurement}_regressed" if measurement else None), - "target_database": args.get("target_database") or None, - "dry_run": str(args.get("dry_run", "false")).lower() == "true", - "min_history": int(args.get("min_history", 50)), - "start_time": args.get("start_time"), - "end_time": args.get("end_time"), + "window": cfg.window, + "start_time": str(cfg.get("start_time") or "").strip() or None, + "end_time": str(cfg.get("end_time") or "").strip() or None, + "model": model, + "output_measurement": output_measurement, + "target_database": str(cfg.get("target_database") or "").strip() or None, + "dry_run": cfg.dry_run, + "skip_existing": cfg.skip_existing, + "min_history": cfg.min_history, + "max_train_rows": cfg.max_train_rows, + "max_predict_rows": cfg.max_predict_rows, + "max_read_rows": cfg.max_read_rows, + "predict_batch_size": cfg.predict_batch_size, + "max_retries": cfg.max_retries, + "request_timeout": request_timeout, } -def _validate_columns(influxdb3_local, cfg) -> None: - """Fail with a clear message if the measurement / target / tag / feature columns do not exist. +# --- Schema and window ----------------------------------------------------- + + +def _ident(name) -> str: + """Quote a SQL identifier, escaping embedded double-quotes. - Uses a parameterized information_schema query, so a typo yields 'field X not found' instead of - an opaque SQL error or a silent empty result. + Identifiers (measurement / field / tag-key names) cannot be passed as query parameters, so they + are interpolated, but quoted and escaped so a name containing a quote can neither break the + query nor inject SQL. Tag and time *values* are passed as bound parameters (see _build_where). + """ + return '"' + str(name).replace('"', '""') + '"' + + +def _resolve_schema(influxdb3_local, cfg) -> dict: + """Read the measurement's columns and types, and reject a configuration the schema cannot serve. + + A column-name check alone is not enough: a tag or string column named as a feature would pass + it, then coerce to null for every row, and the user would see 'only 0 labeled rows' instead of + the real fault. So the target and every feature must be a numeric column here. """ rows = influxdb3_local.query( - "SELECT column_name FROM information_schema.columns WHERE table_name = $m", + "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $m", {"m": cfg["measurement"]}, ) - cols = {r.get("column_name") for r in (rows or [])} - if not cols: - raise ValueError(f"measurement {cfg['measurement']!r} not found (no columns)") - if cfg["field"] not in cols: - raise ValueError(f"target field {cfg['field']!r} not found in {cfg['measurement']!r}") - missing = [k for k in cfg["tags"] if k not in cols] - if missing: - raise ValueError(f"tag column(s) {missing} not found in {cfg['measurement']!r}") - if not cfg["feature_fields"]: - raise ValueError("`feature_fields` is required (the '.'/','-separated feature columns X)") - reserved = [f for f in cfg["feature_fields"] if f in ("time", "y") or f == cfg["field"]] - if reserved: - raise ValueError( - f"feature_fields cannot include the target field or the reserved names time/y: {reserved}" + columns = {r["column_name"]: r.get("data_type", "") for r in (rows or [])} + if not columns: + raise ConfigError( + f"measurement {cfg['measurement']!r} not found (no columns). Check the name and that " + f"the trigger runs against the database holding it." ) - missing_f = [f for f in cfg["feature_fields"] if f not in cols] - if missing_f: - raise ValueError(f"feature field(s) {missing_f} not found in {cfg['measurement']!r}") + tag_names = sorted(k for k, t in columns.items() if t == TAG_DATA_TYPE) + + def _require_numeric(name: str, role: str) -> None: + if name not in columns: + raise ConfigError( + f"{role} {name!r} not found in {cfg['measurement']!r}. Available columns: " + f"{sorted(columns)}" + ) + if columns[name] not in NUMERIC_TYPES: + raise ConfigError( + f"{role} {name!r} is {columns[name]}, not a numeric field. Nori regresses on " + f"numeric columns only." + ) + + _require_numeric(cfg["field"], "target field") + for feature in cfg["feature_fields"]: + _require_numeric(feature, "feature field") + + missing_tags = [k for k in cfg["tags"] if k not in columns] + if missing_tags: + raise ConfigError( + f"tag column(s) {missing_tags} not found in {cfg['measurement']!r}. Tag columns: " + f"{tag_names}" + ) + non_tags = [k for k in cfg["tags"] if k in columns and columns[k] != TAG_DATA_TYPE] + if non_tags: + raise ConfigError( + f"`tags` filter names field column(s) {non_tags}, not tags. Tag columns: {tag_names}" + ) + + # Output points carry provenance tags named model/source/target. A source tag with one of those + # names would overwrite the provenance on write, and would also make the skip_existing lookup + # self-contradictory ("source" = AND "source" = ), so the run + # would never skip and would re-pay for the same rows forever. Refuse up front. + clashing = sorted(set(tag_names) & PROVENANCE_TAGS) + if clashing: + raise ConfigError( + f"{cfg['measurement']!r} has tag column(s) {clashing} whose names collide with the " + f"provenance tags this plugin writes ({sorted(PROVENANCE_TAGS)}). Set " + f"`output_measurement` on a measurement without that clash, or rename the source tag." + ) + + return {"columns": columns, "tag_names": tag_names} + + +def _rfc3339(dt: datetime) -> str: + # A naive value is UTC, never the host's local time: otherwise a query bound would silently + # depend on the server's timezone. + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _parse_iso(name: str, raw: str) -> datetime: + try: + dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError as e: + raise ConfigError( + f"`{name}` must be an ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z), got {raw!r}" + ) from e + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) -def _where_clause(cfg): - """Build the WHERE clause (time window + tag filters) with bound parameters. - Tag and time-range *values* are bound parameters (never string-concatenated) and identifiers are - quote-escaped, so quotes in values/names can't break or inject the query. Returns - (time_clause, tag_clause, params). +def _resolve_window(cfg, now: datetime) -> tuple[datetime, datetime]: + """Resolve the read window, honouring each bound on its own. + + Either bound may be given alone: `start_time` alone reads up to `now`, `end_time` alone reads + the `window` before it. Only when neither is given does the window hang off `now`. An earlier + version fell back to the full default window whenever one bound was missing, which silently + read a different range than the caller asked for. """ - params: dict = {} - tag_clause = "" - for i, (k, v) in enumerate(cfg["tags"].items()): - p = f"tag{i}" - tag_clause += f" AND {_ident(k)} = ${p}" - params[p] = v - if cfg["start_time"] and cfg["end_time"]: - time_clause = "time >= $start_ts AND time < $end_ts" - params["start_ts"] = cfg["start_time"] - params["end_ts"] = cfg["end_time"] + start_raw, end_raw = cfg["start_time"], cfg["end_time"] + if start_raw and end_raw: + start, end = _parse_iso("start_time", start_raw), _parse_iso("end_time", end_raw) + elif start_raw: + start, end = _parse_iso("start_time", start_raw), now + elif end_raw: + end = _parse_iso("end_time", end_raw) + start = end - cfg["window"] else: - # An INTERVAL literal can't be bound, so validate `window` is a clean interval (digits + - # a known unit, no quotes possible) before interpolating it. - _interval_to_timedelta(cfg["window"]) - time_clause = f"time > now() - INTERVAL '{cfg['window']}'" - return time_clause, tag_clause, params + end, start = now, now - cfg["window"] + + if start >= end: + raise ConfigError( + f"empty time window: start {_rfc3339(start)} is not before end {_rfc3339(end)}" + ) + return start, end + +# --- Reading --------------------------------------------------------------- -def _query(influxdb3_local, cfg, task_id) -> pd.DataFrame: - """Read (time, target y, feature columns) for the window into a DataFrame. - A row whose target is null but whose features are present is a prediction target (impute). - Values are coerced to numeric; non-finite values become NaN so they cannot reach the gateway. +def _build_where(tags: dict, start: datetime, end: datetime) -> tuple[str, dict]: + """Build the WHERE clause with bound parameters for the window and every tag value. + + Tag and time *values* are bound parameters, never string-concatenated, and identifiers are + quote-escaped, so a quote in a value or a column name can neither break nor inject the query. """ - feats = cfg["feature_fields"] - time_clause, tag_clause, params = _where_clause(cfg) - feat_cols = ", ".join(_ident(f) for f in feats) + params: dict = {"start_ts": _rfc3339(start), "end_ts": _rfc3339(end)} + clause = "time >= $start_ts AND time < $end_ts" + for i, (key, value) in enumerate(tags.items()): + name = f"tag{i}" + clause += f" AND {_ident(key)} = ${name}" + params[name] = value + return clause, params + + +def _to_float(value): + """Coerce a queried value to a finite float, or None. Booleans are not numbers here.""" + if value is None or isinstance(value, bool): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _read_rows(influxdb3_local, cfg, schema, start, end, task_id) -> list[dict]: + """Read (time, target, features, tags) for the window. + + Every tag column is selected, not only the filtered ones: the series identity decides whether + the run is single-series, and the output points carry it. + """ + features = cfg["feature_fields"] + tag_names = schema["tag_names"] + where, params = _build_where(cfg["tags"], start, end) + # No `AS y` alias: a source tag column literally named `y` would collide with it and make the + # query unplannable. Columns are read back under their own names. + selected = ", ".join( + ["time", _ident(cfg["field"]), *(_ident(f) for f in features), *(_ident(t) for t in tag_names)] + ) + # DESC + LIMIT, reversed below: the row caps alone bound what is SENT to the gateway, but not + # what is READ. `window` is settable from the HTTP body, so without a ceiling here a caller + # could make the plugin pull a whole retention period into this process. The ceiling is well + # above any normal run, and a truncated read warns rather than passing silently. + limit = cfg["max_read_rows"] sql = ( - f"SELECT time, {_ident(cfg['field'])} AS y, {feat_cols} " - f"FROM {_ident(cfg['measurement'])} " - f"WHERE {time_clause}{tag_clause} " - f"ORDER BY time" + f"SELECT {selected} FROM {_ident(cfg['measurement'])} " + f"WHERE {where} ORDER BY time DESC LIMIT {int(limit)}" ) - influxdb3_local.info(f"[{task_id}] query: {sql}") - rows = influxdb3_local.query(sql, params) if params else influxdb3_local.query(sql) - if not rows: - return pd.DataFrame(columns=["time", "y", *feats]) - df = pd.DataFrame(rows) - df["time"] = pd.to_datetime(df["time"], utc=True, errors="coerce") - df = df.dropna(subset=["time"]).sort_values("time").reset_index(drop=True) - df["y"] = pd.to_numeric(df["y"], errors="coerce").replace([np.inf, -np.inf], np.nan) - for f in feats: - df[f] = (pd.to_numeric(df[f], errors="coerce").replace([np.inf, -np.inf], np.nan) - if f in df.columns else np.nan) - return df + influxdb3_local.info( + f"[{task_id}] reading {cfg['measurement']}.{cfg['field']} from " + f"{params['start_ts']} to {params['end_ts']}" + ) + rows = influxdb3_local.query(sql, params) or [] + if len(rows) >= limit: + influxdb3_local.warn( + f"[{task_id}] the window holds at least max_read_rows ({limit}) rows; reading only the " + f"most recent {limit}. Narrow `window` (or raise max_read_rows) so the whole range is " + f"considered." + ) + + parsed: list[dict] = [] + for row in rows: + if row.get("time") is None: + continue + values = [_to_float(row.get(f)) for f in features] + if any(v is None for v in values): + # A row needs every feature to train on or to be predicted. + continue + parsed.append( + { + "time_ns": int(row["time"]), + "y": _to_float(row.get(cfg["field"])), + "x": values, + "series": tuple( + (t, str(row[t])) for t in tag_names if row.get(t) not in (None, "") + ), + } + ) + # Sorted here, not left to the query's DESC ordering: everything downstream reads "the most + # recent rows" as a suffix, so the order is this function's guarantee rather than the engine's. + parsed.sort(key=lambda r: r["time_ns"]) + return parsed -def _call_nori(influxdb3_local, cfg, X_train, y_train, X_test, api_key, task_id): - """Send an in-context regression request to the Nori gateway and return the predictions list. +def _existing_prediction_times(influxdb3_local, cfg, series_tags, start, end, task_id) -> set: + """Timestamps in the window that already hold a prediction from this plugin, for this series. - Raises on a transport error or an unexpected response shape. + Without this, every scheduled tick re-sends a byte-identical payload to a metered endpoint: + predictions land in a separate measurement, so the source rows stay null and nothing converges. + At `every:15m` over a 30d window that is roughly 2,880 paid sends of each row. + + The source series tags are part of the filter, so a sibling trigger writing another series into + the same output measurement cannot make this run believe its own rows are already done. """ - n_features = len(X_train[0]) if X_train else 0 - influxdb3_local.info( - f"[{task_id}] calling Nori: model={cfg['model']} " - f"n_features={n_features} n_train={len(X_train)} n_test={len(X_test)}" + where, params = _build_where(series_tags, start, end) + params["src"] = cfg["measurement"] + params["tgt"] = cfg["field"] + sql = ( + f"SELECT time FROM {_ident(cfg['output_measurement'])} " + f"WHERE {where} AND \"source\" = $src AND \"target\" = $tgt" ) - payload = {"model": cfg["model"], "task": "regression", - "X_train": X_train, "y_train": y_train, "X_test": X_test} - resp = requests.post( - cfg["gateway_url"], - json=payload, - headers={"Content-Type": "application/json", "Authorization": f"Api-Key {api_key}"}, - timeout=120, + try: + if cfg["target_database"]: + rows = influxdb3_local.query(sql, params, database=cfg["target_database"]) + else: + rows = influxdb3_local.query(sql, params) + except Exception as e: # noqa: BLE001 - a missing output measurement is the normal first run + # Warn, not info: on the first run this is expected, but any other failure here means the + # run is about to re-send rows it may already have paid for, and that deserves attention. + influxdb3_local.warn( + f"[{task_id}] could not read existing predictions from {cfg['output_measurement']} " + f"({type(e).__name__}: {e}); treating the whole window as unpredicted, so rows that " + f"already have a prediction may be sent again" + ) + return set() + return {int(r["time"]) for r in (rows or []) if r.get("time") is not None} + + +# --- Gateway --------------------------------------------------------------- + + +def _gateway_url() -> str: + """The gateway endpoint: the module constant, or an operator-set environment override. + + HTTPS is required because the request carries the operator's API key. Loopback is allowed over + plain HTTP so a local mock gateway can be used in tests. + """ + url = os.environ.get(GATEWAY_URL_ENV_VAR, "").strip() or DEFAULT_GATEWAY_URL + # urlsplit, not string slicing: .hostname drops any userinfo, so a URL like + # "http://localhost@evil.example/" resolves to evil.example and is rejected. + try: + parts = urlsplit(url) + except ValueError as e: + # urlsplit itself rejects some malformed authorities (a bracketed host that is not an IP). + # Report that as the configuration error it is, not as an unexplained internal failure. + raise ConfigError(f"{GATEWAY_URL_ENV_VAR} is not a valid URL", detail=str(e)) from e + if parts.scheme == "https" and parts.hostname: + return url + if parts.scheme == "http" and parts.hostname in ("localhost", "127.0.0.1", "::1"): + return url + raise ConfigError( + f"{GATEWAY_URL_ENV_VAR} must be an https:// URL with a host (the request carries the Nori " + f"API key); plain http:// is allowed only for localhost" + ) + + +def _get_api_key(request_headers=None) -> str: + """Resolve the gateway key: a non-empty X-Nori-Api-Key header wins (HTTP trigger), else the + NORI_API_KEY environment variable. + + An empty or non-string header value falls through to the environment rather than winning it: a + header sent with no value used to suppress the fallback and send `Api-Key ` with no key at all. + + The key is deliberately NOT read from the incoming `Authorization` header: InfluxDB parses that + header for its own request authorization, so a custom scheme there never reaches the plugin. + """ + if request_headers: + for key, value in request_headers.items(): + if not (isinstance(key, str) and key.lower() == API_KEY_HEADER.lower()): + continue + if isinstance(value, (list, tuple)): # some servers deliver repeated headers as a list + value = value[0] if value else "" + if isinstance(value, str) and value.strip(): + return value.strip() + key = os.environ.get(API_KEY_ENV_VAR, "").strip() + if not key: + raise ConfigError( + f"No Nori API key. Set the {API_KEY_ENV_VAR} environment variable on the InfluxDB " + f"host, or pass a '{API_KEY_HEADER}: ' header (HTTP trigger)." + ) + return key + + +def _gateway_message(resp) -> str: + """Extract the gateway's own error text. The model returns {"detail": ...}, the gateway wraps + errors as {"error": ...}, and a proxy in front of a cold model can return HTML.""" + try: + payload = resp.json() + except ValueError: + return (resp.text or "")[:200].strip() or "(empty body)" + if isinstance(payload, dict): + for key in ("detail", "error", "message"): + if payload.get(key): + return str(payload[key])[:300] + return str(payload)[:300] + + +def _validate_predictions(preds, expected: int) -> list: + """Check the gateway's predictions before anything downstream trusts them. + + The gateway emits JSON `null` for a row whose prediction is not finite, so a null is a per-row + outcome and is skipped. Anything else non-numeric (a NaN literal, a numeric string) breaks the + documented contract and fails the batch: it would otherwise reach the line-protocol writer and + fail there with an error that never names the gateway. An all-null batch fails too, so a total + upstream failure cannot report success with nothing written. + """ + if not isinstance(preds, list): + raise GatewayError( + f"gateway returned {type(preds).__name__} predictions, expected a list of {expected}" + ) + if len(preds) != expected: + raise GatewayError( + f"gateway returned {len(preds)} predictions for {expected} rows" + ) + checked: list = [] + for i, value in enumerate(preds): + if value is None: + checked.append(None) + continue + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise GatewayError( + f"prediction {i} is {type(value).__name__} ({value!r}), expected a number" + ) + number = float(value) + if not math.isfinite(number): + raise GatewayError(f"prediction {i} is not finite ({value!r})") + checked.append(number) + if not any(v is not None for v in checked): + raise GatewayError( + f"every one of the {expected} predictions is null; the model produced no usable value " + f"for this batch" + ) + return checked + + +def _post_with_retry(influxdb3_local, cfg, payload, api_key, task_id): + """POST to the gateway, retrying a transient fault. + + The local write already retries, so a bare single-attempt gateway call put the retry effort on + the reliable side of the system: a cold model can 503, and the per-key rate limit yields 429. + A read timeout is not retried - it has already spent the whole timeout budget, and it usually + means the slug was never granted to this key rather than that the model is slow. + """ + url = _gateway_url() + headers = {"Content-Type": "application/json", "Authorization": f"Api-Key {api_key}"} + attempts = cfg["max_retries"] + for attempt in range(1, attempts + 1): + try: + resp = requests.post( + url, json=payload, headers=headers, timeout=cfg["request_timeout"] + ) + except requests.exceptions.ReadTimeout as e: + raise GatewayError( + f"gateway did not respond within {cfg['request_timeout']:.0f}s. A cold start takes " + f"60-130s; if this repeats, confirm the key is granted the {cfg['model']!r} slug." + ) from e + except requests.exceptions.RequestException as e: + if attempt >= attempts: + raise GatewayError( + f"could not reach the Nori gateway after {attempts} attempt(s): " + f"{type(e).__name__}" + ) from e + _sleep_backoff(influxdb3_local, attempt, None, task_id, type(e).__name__) + continue + + if resp.status_code < 300: + return resp + detail = _gateway_message(resp) + if resp.status_code in RETRYABLE_STATUS and attempt < attempts: + _sleep_backoff( + influxdb3_local, attempt, resp.headers.get("Retry-After"), task_id, + f"HTTP {resp.status_code}", + ) + continue + # The status and the slug are public facts and the most useful hint a caller can get; the + # gateway's echoed body is not, because a private NORI_GATEWAY_URL can name its own host. + raise GatewayError( + f"Nori gateway returned HTTP {resp.status_code} for model {cfg['model']!r}; " + f"see the plugin logs for this task_id", + detail=detail, + ) + raise GatewayError("gateway retry loop exhausted") # unreachable + + +def _sleep_backoff(influxdb3_local, attempt: int, retry_after, task_id: str, why: str) -> None: + delay = (2 ** (attempt - 1)) + random.random() + if retry_after: + try: + # Retry-After may also be an HTTP-date, which float() rejects; the backoff then stands. + delay = float(retry_after) + except (TypeError, ValueError): + pass + # Clamped at both ends: an absurd Retry-After must not stall the trigger, and a negative one + # must not make time.sleep raise and abort a run that still had healthy attempts left. + delay = max(0.0, min(delay, MAX_BACKOFF_SECONDS)) + influxdb3_local.warn( + f"[{task_id}] gateway attempt {attempt} failed ({why}); retrying in {delay:.1f}s" + ) + time.sleep(delay) + + +def _call_nori(influxdb3_local, cfg, x_train, y_train, x_test, api_key, task_id) -> list: + """Send one in-context regression request and return the validated predictions.""" + influxdb3_local.info( + f"[{task_id}] calling Nori: model={cfg['model']} n_features={len(x_train[0])} " + f"n_train={len(x_train)} n_test={len(x_test)}" ) - resp.raise_for_status() - result = resp.json() - preds = result.get("predictions") - if not isinstance(preds, list) or len(preds) != len(X_test): - raise ValueError(f"unexpected Nori response: {str(result)[:300]}") + payload = { + "model": cfg["model"], + "task": "regression", + "X_train": x_train, + "y_train": y_train, + "X_test": x_test, + } + resp = _post_with_retry(influxdb3_local, cfg, payload, api_key, task_id) + try: + result = resp.json() + except ValueError as e: + # A proxy in front of a scaled-to-zero model can answer 200 with an HTML page. Naming the + # gateway here keeps the fault from being reported as a bad request body. + raise GatewayError( + f"gateway returned a non-JSON body (HTTP {resp.status_code}, " + f"content-type {resp.headers.get('Content-Type', 'unknown')!r})" + ) from e + if not isinstance(result, dict): + raise GatewayError(f"gateway returned {type(result).__name__}, expected a JSON object") influxdb3_local.info(f"[{task_id}] Nori usage={result.get('usage', {})}") - return preds + return _validate_predictions(result.get("predictions"), len(x_test)) + +# --- Regression ------------------------------------------------------------ -def _regress(influxdb3_local, cfg, api_key, task_id): - """Predict the target `field` from `feature_fields` on the same rows (tabular regression). - Trains on rows where the target is present and predicts the rows where it is null (impute / - backfill), returning (timestamps_of_predicted_rows, predictions). No time features and no - ordering assumptions: Nori sees only the feature columns. +def _regress(influxdb3_local, cfg, schema, start, end, api_key, task_id) -> tuple[list, list, dict, int]: + """Predict the target from the feature columns on the same rows (tabular regression). + + Trains on the rows where the target is present and predicts the rows where it is null + (imputation / backfill). Nori sees only the feature columns: no time features, no ordering. """ - feats = cfg["feature_fields"] - df = _query(influxdb3_local, cfg, task_id) - df = df.dropna(subset=feats) # a row needs all features present to train on or predict - train = df[df["y"].notna()] - test = df[df["y"].isna()] + rows = _read_rows(influxdb3_local, cfg, schema, start, end, task_id) + if not rows: + influxdb3_local.warn( + f"[{task_id}] no rows with all of {cfg['feature_fields']} present in the window; " + f"skipping" + ) + return [], [], {}, 0 + + # Series identity decides whether this run is single-series. Predictions are written back at + # each row's own timestamp, so two series in one run would train as one model and write points + # that cannot be traced to either. Counting distinct tag combinations catches that even when + # the two series never share a timestamp, which a duplicate-timestamp check missed. + series = {r["series"] for r in rows} + if len(series) > 1: + listed = ", ".join( + "{" + ", ".join(f"{k}={v}" for k, v in s) + "}" for s in sorted(series)[:3] + ) + raise ConfigError( + f"the window holds {len(series)} series (e.g. {listed}), but a run must resolve to " + f"one: predictions are written at each row's own timestamp, so several series would " + f"train as one model and collide on write. Add a `tags` filter that isolates a single " + f"series, or run one trigger per series." + ) + series_tags = dict(next(iter(series))) + + train = [r for r in rows if r["y"] is not None] + test = [r for r in rows if r["y"] is None] + if len(train) < cfg["min_history"]: influxdb3_local.warn( - f"[{task_id}] only {len(train)} labeled rows (< min_history {cfg['min_history']}); skipping" + f"[{task_id}] only {len(train)} labeled rows (< min_history {cfg['min_history']}); " + f"skipping" ) - return [], [] - if len(test) == 0: + return [], [], series_tags, 0 + if not test: influxdb3_local.warn( - f"[{task_id}] no rows to predict: every '{cfg['field']}' value in the window is already " - f"present (this plugin fills rows where the target is null); skipping" - ) - return [], [] - # Predictions are written back at each test row's timestamp. If two rows share a timestamp - # (a multi-series measurement not isolated by `tags`), their output points would have an - # identical measurement+tag+time key and silently overwrite each other, so fail loud instead. - dup = pd.Index(test["time"])[pd.Index(test["time"]).duplicated()].unique() - if len(dup): - raise ValueError( - f"matched multiple series sharing {len(dup)} timestamp(s), so writing predictions " - f"back would collide and silently drop values. Add a `tags` filter that isolates a " - f"single series (or run one trigger per series). First colliding time: " - f"{pd.Timestamp(dup[0])}" - ) - X_train = train[feats].to_numpy().tolist() - y_train = train["y"].to_numpy().tolist() - X_test = test[feats].to_numpy().tolist() - preds = _call_nori(influxdb3_local, cfg, X_train, y_train, X_test, api_key, task_id) - return list(pd.DatetimeIndex(test["time"])), preds - - -class _BatchLines: - """Wrap several LineBuilder objects so write_sync / write_sync_to_db can write them in one call. - - build() returns the newline-joined line protocol; each LineBuilder exposes its own build(). - """ + f"[{task_id}] no rows to predict: every '{cfg['field']}' value in the window is " + f"already present (this plugin fills rows where the target is null); skipping" + ) + return [], [], series_tags, 0 + + if cfg["skip_existing"]: + done = _existing_prediction_times( + influxdb3_local, cfg, series_tags, start, end, task_id + ) + if done: + before = len(test) + test = [r for r in test if r["time_ns"] not in done] + influxdb3_local.info( + f"[{task_id}] skip_existing: {before - len(test)} of {before} rows already hold a " + f"prediction in {cfg['output_measurement']}" + ) + if not test: + influxdb3_local.info( + f"[{task_id}] every row in the window already holds a prediction; skipping " + f"(set skip_existing=false to refresh them)" + ) + return [], [], series_tags, 0 + + # Caps keep one run bounded: the query has no LIMIT, so a wide window on a high-rate series + # would otherwise build a single multi-million-row payload against the gateway's 100 MB cap. + # The most recent rows are kept, since they are the ones a backfill cares about. + if len(train) > cfg["max_train_rows"]: + influxdb3_local.warn( + f"[{task_id}] {len(train)} labeled rows exceed max_train_rows " + f"{cfg['max_train_rows']}; training on the most recent {cfg['max_train_rows']}" + ) + train = train[-cfg["max_train_rows"] :] + if len(test) > cfg["max_predict_rows"]: + influxdb3_local.warn( + f"[{task_id}] {len(test)} rows to predict exceed max_predict_rows " + f"{cfg['max_predict_rows']}; predicting the most recent {cfg['max_predict_rows']} " + f"and leaving {len(test) - cfg['max_predict_rows']} for a later run" + ) + test = test[-cfg["max_predict_rows"] :] + + x_train = [r["x"] for r in train] + y_train = [r["y"] for r in train] + + batch_size = cfg["predict_batch_size"] + batches = [test[i : i + batch_size] for i in range(0, len(test), batch_size)] + if len(batches) > 1: + influxdb3_local.info( + f"[{task_id}] predicting {len(test)} rows in {len(batches)} batches of up to " + f"{batch_size}; each batch re-sends the {len(x_train)}-row training context and is " + f"billed separately" + ) + + out_times: list = [] + out_preds: list = [] + unfinished = 0 + for number, batch in enumerate(batches, 1): + try: + preds = _call_nori( + influxdb3_local, cfg, x_train, y_train, [r["x"] for r in batch], api_key, task_id + ) + except PublicError as e: + # Batches already returned are paid for. Losing them would make the next run buy the + # same predictions again, so keep what succeeded and report the shortfall instead. + if not out_preds: + raise + unfinished = len(test) - len(out_times) + influxdb3_local.warn( + f"[{task_id}] batch {number} of {len(batches)} failed ({e}); keeping the " + f"{len(out_preds)} predictions already returned and leaving {unfinished} row(s) " + f"for a later run" + ) + break + out_times.extend(r["time_ns"] for r in batch) + out_preds.extend(preds) - def __init__(self, line_builders): - self._line_builders = list(line_builders) + return out_times, out_preds, series_tags, unfinished - def build(self) -> str: - lines = [str(b.build()) for b in self._line_builders] - if not lines: - raise ValueError("no line protocol to write") - return "\n".join(lines) +# --- Writing --------------------------------------------------------------- -def _write_predictions(influxdb3_local, cfg, out_times, preds, task_id, max_retries=3) -> int: - """Write predictions with write_sync so write errors surface during trigger execution. - Buffered write()/write_to_db() only accumulate points and flush after the trigger returns, so - the plugin never learns whether the write succeeded. write_sync/write_sync_to_db (InfluxDB - 3.8.2+) write immediately and raise on failure; the points are batched into one line-protocol - payload. On repeated failure this raises, so the caller reports "failed" rather than a bogus - success count. +def _write_predictions(influxdb3_local, cfg, out_times, preds, series_tags, task_id) -> int: + """Write the predictions with write_sync so a write error surfaces during trigger execution. + + Buffered write()/write_to_db() only queue points and flush after the trigger returns, so the + plugin never learns whether the write succeeded. write_sync/write_sync_to_db (InfluxDB 3.8.2+) + write immediately and raise on failure. + + The retry policy is the shared helper's, which catches every exception, so a permanent fault + (a field-type conflict on the output measurement, say) spends the backoff before it fails. + That cannot be narrowed by exception type from here: the engine raises a bare + `builtins.Exception` for every write failure, transport and schema alike, with only the message + to tell them apart (verified on 3.10.3). Classifying on message text would be worse than the + wait, so the knob is the wait: `max_retries=1` writes once and fails fast. """ builders = [] - for ts, value in zip(out_times, preds): - if value is None: + skipped = 0 + for time_ns, value in zip(out_times, preds): + if value is None: # a row the model could not produce a finite value for + skipped += 1 continue - line = (LineBuilder(cfg["output_measurement"]) - .tag("model", cfg["model"]) - .tag("source", cfg["measurement"]) - .tag("target", cfg["field"])) # the predicted field - for k, v in cfg["tags"].items(): # echo single-value filter tags - line = line.tag(k, v) + line = ( + LineBuilder(cfg["output_measurement"]) # noqa: F821 - engine-provided global + .tag("model", cfg["model"]) + .tag("source", cfg["measurement"]) + .tag("target", cfg["field"]) + ) + # The source series' own tags, not just the ones the caller filtered on, so a point can + # always be traced back to the series it was predicted for. + for key, tag_value in series_tags.items(): + line = line.tag(key, tag_value) line = line.float64_field("value", float(value)) - line = line.time_ns(int(pd.Timestamp(ts).value)) # pandas ns since epoch + line = line.time_ns(int(time_ns)) builders.append(line) + if skipped: + influxdb3_local.warn( + f"[{task_id}] {skipped} row(s) had no finite prediction and were not written" + ) if not builders: return 0 - batch = _BatchLines(builders) - db = cfg["target_database"] - for attempt in range(max_retries): - try: - if db: - influxdb3_local.write_sync_to_db(db, batch, no_sync=True) - else: - influxdb3_local.write_sync(batch, no_sync=True) - return len(builders) - except Exception as e: # noqa: BLE001 - if attempt < max_retries - 1: - time.sleep((2 ** attempt) + random.random()) - else: - influxdb3_local.error(f"[{task_id}] write_sync failed after {max_retries} attempts: {e}") - raise - return 0 + write_data( + influxdb3_local, + builders, + batch=True, + retries=cfg["max_retries"] - 1, + no_sync=True, + database=cfg["target_database"], + ) + return len(builders) + +# --- Run ------------------------------------------------------------------- -def _run(influxdb3_local, cfg, api_key, task_id) -> dict: - if not cfg["measurement"] or not cfg["field"]: - raise ValueError("`measurement` and `field` are required") - _validate_columns(influxdb3_local, cfg) - out_times, preds = _regress(influxdb3_local, cfg, api_key, task_id) + +def _run(influxdb3_local, cfg, api_key, now, task_id) -> dict: + schema = _resolve_schema(influxdb3_local, cfg) + start, end = _resolve_window(cfg, now) + out_times, preds, series_tags, unfinished = _regress( + influxdb3_local, cfg, schema, start, end, api_key, task_id + ) if not preds: return {"status": "skipped", "written": 0} if cfg["dry_run"]: influxdb3_local.info(f"[{task_id}] dry_run: first preds={preds[:5]}") - return {"status": "dry_run", "predictions": preds} - written = _write_predictions(influxdb3_local, cfg, out_times, preds, task_id) - influxdb3_local.info(f"[{task_id}] wrote {written} predictions to {cfg['output_measurement']}") + return {"status": "dry_run", "written": 0, "predictions": preds} + written = _write_predictions( + influxdb3_local, cfg, out_times, preds, series_tags, task_id + ) + influxdb3_local.info( + f"[{task_id}] wrote {written} predictions to {cfg['output_measurement']}" + ) + if unfinished: + # A gateway fault stopped the run part-way. The batches that did return are written, but + # reporting "success" would hide the shortfall from a caller who cannot read the log. + return {"status": "partial", "written": written, "remaining": unfinished} return {"status": "success", "written": written} # --- Entry points ---------------------------------------------------------- + def process_scheduled_call(influxdb3_local, call_time, args=None): + """Scheduled entry point: impute the rows still missing the target in the trailing window.""" task_id = str(uuid.uuid4()) try: - cfg = _build_config(args) - api_key = _get_api_key() # scheduled: no request headers -> env var only - _run(influxdb3_local, cfg, api_key, task_id) + cfg = _load_config(args) + api_key = _get_api_key() # scheduled: no request headers, so the env var only + # The engine passes call_time as naive UTC; anchoring the window to it keeps the range + # reproducible for a given tick. + now = datetime.now(timezone.utc) + if isinstance(call_time, datetime): + now = call_time.replace(tzinfo=timezone.utc) if not call_time.tzinfo else call_time + _run(influxdb3_local, cfg, api_key, now, task_id) except Exception as e: # noqa: BLE001 - never let a scheduled run crash the engine - influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {e}") + influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {_log_text(e)}") def process_request(influxdb3_local, query_parameters, request_headers, request_body, args=None): + """HTTP entry point: impute on demand, optionally over an explicit start_time/end_time window.""" task_id = str(uuid.uuid4()) + + # Decoding the body is its own step: requests.exceptions.JSONDecodeError subclasses + # json.JSONDecodeError, so a shared handler reported a gateway that answered with non-JSON as + # 'invalid JSON in request body' and sent the operator to debug the wrong system. + try: + body = _decode_body(request_body) + except ConfigError as e: + influxdb3_local.error(f"[{task_id}] {e}") + return {"status": "failed", "task_id": task_id, "message": str(e)} + try: - body = {} - if request_body: - body = json.loads(request_body) - # scalar body values override trigger args - merged = {**(args or {}), **{k: str(v) for k, v in body.items() if not isinstance(v, (list, dict))}} - cfg = _build_config(merged) - # complex body values are dropped by the scalar filter above, so handle them explicitly: - if isinstance(body.get("tags"), dict): - cfg["tags"] = {str(k): str(v) for k, v in body["tags"].items()} - if isinstance(body.get("feature_fields"), list): - cfg["feature_fields"] = list(dict.fromkeys(str(x) for x in body["feature_fields"])) + cfg = _load_config(args, body) api_key = _get_api_key(request_headers) - result = _run(influxdb3_local, cfg, api_key, task_id) - # surface the real outcome at the top level (success / skipped / dry_run), not always + result = _run(influxdb3_local, cfg, api_key, datetime.now(timezone.utc), task_id) + # Surface the real outcome (success / skipped / dry_run) at the top level, not always # "success", so a caller can tell a real prediction from a no-op. - return {"status": result.get("status", "success"), "task_id": task_id, "result": result} - except json.JSONDecodeError: - influxdb3_local.error(f"[{task_id}] invalid JSON body") - return {"status": "failed", "message": "invalid JSON in request body"} + return {"status": result["status"], "task_id": task_id, "result": result} + except PublicError as e: + # The message is written for the caller; anything that must not travel is in e.detail and + # goes only to the log. + influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {e.log_text()}") + return {"status": "failed", "task_id": task_id, "message": str(e)} except Exception as e: # noqa: BLE001 + # Anything else can carry internal detail (a storage error, a database name), so the detail + # goes to the log and the caller gets a stable message plus the task id to correlate on. influxdb3_local.error(f"[{task_id}] {type(e).__name__}: {e}") - return {"status": "failed", "message": str(e)} + return { + "status": "failed", + "task_id": task_id, + "message": "internal error; see the plugin logs for this task_id", + } + + +def _decode_body(request_body) -> dict: + """Decode the HTTP request body into a dict. An empty body means 'use the trigger arguments'.""" + if request_body is None or request_body == "" or request_body == b"": + return {} + if isinstance(request_body, dict): + return request_body + if not isinstance(request_body, (str, bytes, bytearray)): + # Guarded before len(): an unexpected type would otherwise raise TypeError straight out of + # process_request, bypassing the sanitised error contract. + raise ConfigError( + f"request body must be JSON text, got {type(request_body).__name__}" + ) + if len(request_body) > MAX_BODY_BYTES: + raise ConfigError(f"request body exceeds {MAX_BODY_BYTES // (1024 * 1024)} MiB") + try: + text = ( + request_body.decode("utf-8") + if isinstance(request_body, (bytes, bytearray)) + else request_body + ) + body = json.loads(text) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + raise ConfigError("request body is not valid JSON") from e + if not isinstance(body, dict): + raise ConfigError("request body must be a JSON object") + return body diff --git a/influxdata/nori_regression/nori_regression_config_scheduler.toml b/influxdata/nori_regression/nori_regression_config_scheduler.toml new file mode 100644 index 0000000..c113010 --- /dev/null +++ b/influxdata/nori_regression/nori_regression_config_scheduler.toml @@ -0,0 +1,56 @@ +# Nori Regression plugin configuration for a scheduled trigger. +# Use via the config_file_path trigger argument (path relative to PLUGIN_DIR). +# This file supplies EVERY parameter: it is mutually exclusive with inline trigger arguments +# and with an HTTP request body. + +########## Required ########## +# Source measurement (table) to read from. +measurement = "sensors" +# The numeric field to predict. The plugin trains on the rows where it is present and predicts +# (imputes) the rows where it is null. +field = "pressure" +# Numeric feature columns (X) used to predict `field`. A TOML array is the only form that can +# name a column containing a space. +feature_fields = ["temp", "humidity"] + +########## Optional ########## +# Time window of rows to read, ending at the trigger's call time. +# Units: s, min, h, d, w (integer magnitude). Default: "30d". +#window = "30d" +# Fixed window instead of a trailing one. With skip_existing left on, a schedule over a fixed +# window backfills and then stops calling the gateway. Either bound may be given alone. +#start_time = "2026-01-01T00:00:00Z" +#end_time = "2026-02-01T00:00:00Z" +# Filter to a single series. A run must resolve to one series; the plugin fails if the window +# holds more. Default: no filter. +#tags = { site = "A" } +# Nori gateway slug. "synthefy/nori-30m" (default, ~29M parameters) or "synthefy/nori-6m" +# (cheaper, faster cold start). The bare "synthefy/nori" slug is retired. +#model = "synthefy/nori-30m" +# Measurement to write predictions to. Default: "_regressed". +#output_measurement = "sensors_regressed" +# Write predictions to another database. Default: the trigger's database. +#target_database = "predictions" +# Log the predictions without writing them. Default: false. +#dry_run = false +# Skip rows that already hold a prediction in output_measurement, so a repeating schedule does +# not re-send and re-bill the same rows. Set false to refresh earlier predictions with newer +# training data. Default: true. +#skip_existing = true +# Minimum labeled rows (target present) required to train; the run is skipped below this. +# Default: 50. +#min_history = 50 +# Cap on labeled rows sent as the in-context training set; the most recent rows are kept. +# The gateway bills per training row and column, so this is the main cost control. Default: 1000. +#max_train_rows = 1000 +# Cap on rows predicted per run; the most recent rows are kept and the rest wait for a later +# run. Default: 5000. +#max_predict_rows = 5000 +# Rows per gateway call. Each batch re-sends the training context and is billed separately, so a +# larger value costs less. Default: 1000. +#predict_batch_size = 1000 +# Timeout for one gateway call. A cold start has been measured at 60-130s, so keep this well +# above that. Default: "300s". +#request_timeout = "300s" +# Maximum attempts per gateway call and per write (1 disables retry). Default: 3. +#max_retries = 3 diff --git a/influxdata/nori_regression/requirements.txt b/influxdata/nori_regression/requirements.txt index 6d71a56..82dcab6 100644 --- a/influxdata/nori_regression/requirements.txt +++ b/influxdata/nori_regression/requirements.txt @@ -1,3 +1,2 @@ +influxdata-plugin-utils>=0.2.0 requests -pandas -numpy diff --git a/influxdata/nori_regression/test_nori_regression.py b/influxdata/nori_regression/test_nori_regression.py new file mode 100644 index 0000000..32ce464 --- /dev/null +++ b/influxdata/nori_regression/test_nori_regression.py @@ -0,0 +1,1426 @@ +"""Unit tests for the nori_regression plugin. + +Mirrors the mock-based approach used across this repo: a fake influxdb3_local, a fake LineBuilder +and a fake `requests` module, so no engine and no network are needed. + + pytest influxdata/nori_regression/test_nori_regression.py +""" + +import json +import math +import os +import sys +from datetime import datetime, timedelta, timezone + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +import nori_regression as nr # noqa: E402 + +EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) +NS = 1_000_000_000 + +BASE_ARGS = { + "measurement": "sensors", + "field": "pressure", + "feature_fields": "temp humidity", +} + +SENSOR_SCHEMA = [ + {"column_name": "time", "data_type": "Timestamp(Nanosecond, None)"}, + {"column_name": "pressure", "data_type": "Float64"}, + {"column_name": "temp", "data_type": "Float64"}, + {"column_name": "humidity", "data_type": "Float64"}, + {"column_name": "status", "data_type": "Utf8"}, + {"column_name": "site", "data_type": nr.TAG_DATA_TYPE}, +] + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class FakeLineBuilder: + def __init__(self, measurement): + self.measurement = measurement + self.tags = [] + self.fields = {} + self.timestamp = None + + def tag(self, key, value): + self.tags.append((key, value)) + return self + + def float64_field(self, key, value): + self.fields[key] = value + return self + + def time_ns(self, timestamp_ns): + self.timestamp = timestamp_ns + return self + + def build(self): + line = self.measurement + if self.tags: + line += "," + ",".join(f"{k}={v}" for k, v in self.tags) + line += " " + ",".join(f"{k}={v}" for k, v in self.fields.items()) + if self.timestamp is not None: + line += f" {self.timestamp}" + return line + + +class FakeLocal: + """Query responses are keyed by a SQL substring, longest key first.""" + + def __init__(self, query_responses=None): + self.query_responses = dict(query_responses or {}) + self.queries = [] + self.infos, self.warns, self.errors = [], [], [] + self.writes = [] + self.fail_writes = False + + def info(self, *a): + self.infos.append(" ".join(str(x) for x in a)) + + def warn(self, *a): + self.warns.append(" ".join(str(x) for x in a)) + + def error(self, *a): + self.errors.append(" ".join(str(x) for x in a)) + + def query(self, sql, args=None, database=None): + self.queries.append({"sql": sql, "args": args, "database": database}) + for key in sorted(self.query_responses, key=len, reverse=True): + if key in sql: + rows = self.query_responses[key] + if isinstance(rows, Exception): + raise rows + return rows + return [] + + def write_sync(self, batch, no_sync=False): + if self.fail_writes: + raise RuntimeError("write refused") + self.writes.append((None, batch.build())) + + def write_sync_to_db(self, database, batch, no_sync=False): + if self.fail_writes: + raise RuntimeError("write refused") + self.writes.append((database, batch.build())) + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=None, headers=None): + self.status_code = status_code + self._payload = payload + self.text = text if text is not None else json.dumps(payload) + self.headers = headers or {"Content-Type": "application/json"} + + def json(self): + if self._payload is None: + raise ValueError("no JSON") + return self._payload + + +class FakeRequests: + """Stands in for the `requests` module: replays a script of responses/exceptions.""" + + exceptions = __import__("requests").exceptions + + def __init__(self, script): + self.script = list(script) + self.calls = [] + + def post(self, url, json=None, headers=None, timeout=None): + self.calls.append( + {"url": url, "json": json, "headers": headers, "timeout": timeout} + ) + item = self.script.pop(0) if self.script else FakeResponse(200, {"predictions": []}) + if isinstance(item, Exception): + raise item + return item + + +@pytest.fixture(autouse=True) +def _plugin_env(monkeypatch): + monkeypatch.setattr(nr, "LineBuilder", FakeLineBuilder, raising=False) + monkeypatch.setattr(nr.time, "sleep", lambda _s: None) + monkeypatch.delenv(nr.API_KEY_ENV_VAR, raising=False) + monkeypatch.delenv(nr.GATEWAY_URL_ENV_VAR, raising=False) + monkeypatch.setenv("PLUGIN_DIR", "/tmp") + yield + + +def rows(specs, site="A"): + """Build query rows: (offset_seconds, target_or_None, temp, humidity). + + Columns come back under their own names — the plugin does not alias the target to `y`, because + a source tag column named `y` would collide with the alias and make the query unplannable. + """ + return [ + {"time": t * NS, "pressure": y, "temp": temp, "humidity": hum, "site": site} + for t, y, temp, hum in specs + ] + + +def labeled(count, start=0, site="A"): + return rows( + [(start + i, 1000.0 + i, 20.0 + i % 5, 40.0 + i % 3) for i in range(count)], + site=site, + ) + + +def unlabeled(count, start=10_000, site="A"): + return rows( + [(start + i, None, 21.0 + i % 5, 41.0 + i % 3) for i in range(count)], site=site + ) + + +def cfg_for(**overrides): + args = dict(BASE_ARGS) + args.update(overrides) + return nr._load_config(args) + + +# --------------------------------------------------------------------------- +# Docstring metadata +# --------------------------------------------------------------------------- + + +def test_docstring_metadata_declares_every_accepted_parameter(): + """Explorer renders only declared parameters, so the declaration must not lag the code.""" + meta = json.loads(nr.__doc__) + assert meta["plugin_type"] == ["scheduled", "http"] + accepted = { + "measurement", + "field", + "feature_fields", + "window", + "start_time", + "end_time", + "tags", + "model", + "output_measurement", + "target_database", + "dry_run", + "skip_existing", + "min_history", + "max_train_rows", + "max_predict_rows", + "max_read_rows", + "predict_batch_size", + "request_timeout", + "max_retries", + "config_file_path", + } + for section in ("scheduled_args_config", "http_args_config"): + declared = {a["name"] for a in meta[section]} + assert declared == accepted, f"{section} drifted: {declared ^ accepted}" + for arg in meta[section]: + assert arg["description"] and arg["example"] is not None + assert isinstance(arg["required"], bool) + + +def test_every_validator_key_is_declared(): + meta = json.loads(nr.__doc__) + declared = {a["name"] for a in meta["scheduled_args_config"]} + for validator in nr.VALIDATORS: + for name in validator.names: + assert name in declared + + +# --------------------------------------------------------------------------- +# Body-override allowlist (the security regression) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "body", + [ + {"measurement": "@format {env[NORI_API_KEY]}"}, + {"field": "@read_file /etc/passwd"}, + {"measurement": "@jinja {{env.NORI_API_KEY}}"}, + {"measurement": "@get other"}, + {"feature_fields": ["@format {env[NORI_API_KEY]}", "temp"]}, + {"tags": {"site": "@read_file /etc/passwd"}}, + {"window": "@json [1]"}, + ], +) +def test_a_body_value_cannot_be_a_dynaconf_substitution_token(body, monkeypatch): + """The allowlist gates key NAMES; without this the VALUES still reach dynaconf, which resolves + @format/@read_file/@jinja and hands the host's environment or files back to the caller.""" + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "SECRET-KEY-abc123") + with pytest.raises(nr.ConfigError, match="may not begin with"): + nr._load_config({}, {**BASE_ARGS, **body}) + + +def test_a_trigger_argument_cannot_be_a_substitution_token_either(): + with pytest.raises(nr.ConfigError, match="trigger argument value"): + cfg_for(output_measurement="@read_file /etc/passwd") + + +@pytest.mark.parametrize("value", ["a@b", "sensors@1", "user@example.com"]) +def test_an_at_sign_that_is_not_a_leading_token_is_allowed(value): + """Only a LEADING '@' triggers dynaconf, so a legitimate value containing one must still work. + + The guard is deliberately stricter than dynaconf at the boundary: any leading '@' is refused, + including forms dynaconf would treat as inert, so the rule stays one sentence long. + """ + cfg = nr._load_config({}, {**BASE_ARGS, "measurement": value}) + assert cfg["measurement"] == value + + +def test_the_leak_does_not_survive_end_to_end(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "SECRET-KEY-abc123") + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + result, _ = _http(local, {"measurement": "@format {env[NORI_API_KEY]}"}) + assert result["status"] == "failed" + assert "SECRET-KEY-abc123" not in json.dumps(result) + + +@pytest.mark.parametrize( + "key", + [ + "MEASUREMENT", # dynaconf keys are case-insensitive; the allowlist must still be closed + "Target_Database", + "CONFIG_FILE_PATH", + " measurement ", + "__class__", + "measurement", # unicode look-alike + ], +) +def test_the_allowlist_is_closed_against_key_tricks(key): + args = {**BASE_ARGS, "target_database": "opdb", "output_measurement": "op_out"} + with pytest.raises(nr.ConfigError, match="may not set"): + nr._load_config(args, {key: "evil"}) + + +@pytest.mark.parametrize( + "key, value", + [ + ("gateway_url", "https://attacker.example/collect"), + ("target_database", "someone_elses_db"), + ("output_measurement", "victim"), + ("model", "synthefy/nori-30m-thinking-medium"), + ("min_history", "0"), + ("max_train_rows", "1000000"), + ("max_predict_rows", "1000000"), + ("max_read_rows", "100000000"), + ("predict_batch_size", "1"), + ("request_timeout", "1s"), + ("max_retries", "99"), + ("skip_existing", "false"), + ("config_file_path", "/etc/passwd"), + ], +) +def test_request_body_cannot_set_operator_parameters(key, value): + """A caller holding only a database token must not redirect the key, the write target, or cost.""" + with pytest.raises(nr.ConfigError) as excinfo: + nr._load_config(dict(BASE_ARGS), {key: value}) + assert key in str(excinfo.value) + + +def test_gateway_url_is_not_a_parameter_at_all(): + """A trigger argument cannot move the endpoint either, and is rejected rather than ignored.""" + with pytest.raises(nr.ConfigError, match=nr.GATEWAY_URL_ENV_VAR): + cfg_for(gateway_url="https://attacker.example/collect") + assert nr._gateway_url() == nr.DEFAULT_GATEWAY_URL + + +@pytest.mark.parametrize("key", sorted(nr.REMOVED_PARAMETERS)) +def test_a_removed_parameter_is_rejected_not_ignored(key): + """An operator following the plugin's earlier documentation gets a message, not silence.""" + with pytest.raises(nr.ConfigError, match=f"`{key}` is not a parameter"): + cfg_for(**{key: "1"}) + + +def test_request_body_may_set_query_shape_keys_the_trigger_left_open(): + cfg = nr._load_config( + {}, + { + "measurement": "other", + "field": "flow", + "feature_fields": ["a", "b"], + "tags": {"site": "B"}, + "window": "2d", + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2026-01-02T00:00:00Z", + "dry_run": True, + }, + ) + assert cfg["measurement"] == "other" + assert cfg["field"] == "flow" + assert cfg["feature_fields"] == ["a", "b"] + assert cfg["tags"] == {"site": "B"} + assert cfg["window"] == timedelta(days=2) + assert cfg["dry_run"] is True + assert cfg["output_measurement"] == "other_regressed" + + +@pytest.mark.parametrize("key", sorted(nr.BODY_OVERRIDABLE_KEYS)) +def test_a_trigger_argument_pins_its_value_against_the_body(key): + """`measurement` is body-settable and `output_measurement` defaults from it, so a body that + could override a pinned `measurement` would re-point the operator's write target.""" + args = {**BASE_ARGS, "window": "1d", "tags": "site:A", "start_time": "2026-01-01T00:00:00Z", + "end_time": "2026-01-02T00:00:00Z", "dry_run": "false"} + with pytest.raises(nr.ConfigError, match=f"may not override \\['{key}'\\]"): + nr._load_config(args, {key: "x" if key != "dry_run" else True}) + + +def test_explicit_null_in_body_falls_back_to_the_default(): + cfg = nr._load_config({"measurement": "sensors", "field": "pressure", + "feature_fields": "temp humidity"}, {"window": None}) + assert cfg["window"] == timedelta(days=30) + + +def test_toml_config_and_request_body_are_mutually_exclusive(tmp_path): + path = tmp_path / "cfg.toml" + path.write_text('measurement = "sensors"\nfield = "pressure"\n') + with pytest.raises(nr.ConfigError, match="must be empty"): + nr._load_config({"config_file_path": str(path)}, {"window": "1d"}) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +def test_toml_config_supplies_every_parameter(tmp_path): + path = tmp_path / "cfg.toml" + path.write_text( + 'measurement = "sensors"\n' + 'field = "pressure"\n' + 'feature_fields = ["air temp", "humidity"]\n' + 'window = "7d"\n' + "min_history = 10\n" + ) + cfg = nr._load_config({"config_file_path": str(path)}) + # A TOML list reaches the plugin natively, which is the only way to name a column + # containing a space. + assert cfg["feature_fields"] == ["air temp", "humidity"] + assert cfg["window"] == timedelta(days=7) + assert cfg["min_history"] == 10 + + +def test_toml_config_cannot_be_combined_with_inline_arguments(tmp_path): + """"Supplies all parameters" has to be enforced, or the inline ones vanish in silence.""" + path = tmp_path / "cfg.toml" + path.write_text('measurement = "sensors"\nfield = "pressure"\nfeature_fields = ["temp"]\n') + with pytest.raises(nr.ConfigError, match="cannot be combined with the inline"): + nr._load_config({"config_file_path": str(path), "window": "99d"}) + + +def test_the_shipped_toml_template_loads(): + """Catches drift between the template and the parameters the code accepts.""" + template = os.path.join(os.path.dirname(__file__), "nori_regression_config_scheduler.toml") + cfg = nr._load_config({"config_file_path": template}) + assert cfg["measurement"] == "sensors" + assert cfg["field"] == "pressure" + assert cfg["feature_fields"] == ["temp", "humidity"] + + +def test_a_toml_failure_keeps_the_host_path_out_of_the_caller_message(tmp_path): + """A caller reaches this by posting an empty body to a TOML-configured trigger.""" + missing = tmp_path / "nope.toml" + with pytest.raises(nr.ConfigError) as excinfo: + nr._load_config({"config_file_path": str(missing)}) + assert str(excinfo.value) == ( + "the trigger's TOML configuration could not be loaded; see the plugin logs for this task_id" + ) + assert str(missing) not in str(excinfo.value) + assert str(missing) in excinfo.value.log_text() + + +def test_a_malformed_toml_is_reported_the_same_way(tmp_path): + path = tmp_path / "bad.toml" + path.write_text('measurement = "a"\nmeasurement = "b"\n') + with pytest.raises(nr.ConfigError, match="could not be loaded"): + nr._load_config({"config_file_path": str(path)}) + + +@pytest.mark.parametrize( + "args, fragment", + [ + ({"measurement": ""}, "`measurement` is required"), + ({"field": ""}, "`field` is required"), + ({"feature_fields": ""}, "`feature_fields` is required"), + ({"feature_fields": "temp pressure"}, "cannot include the target field"), + ({"feature_fields": "temp time"}, "cannot include the target field or 'time'"), + ({"tags": "site=A"}, "invalid `tags`"), + ({"tags": "site"}, "invalid `tags`"), + ({"model": "synthefy/nori"}, "retired"), + ({"output_measurement": "sensors"}, "must differ from measurement"), + ({"min_history": "5000"}, "exceeds max_train_rows"), + ({"window": "1.5h"}, "invalid configuration"), + ({"window": "5m"}, "invalid configuration"), + ({"min_history": "0"}, "invalid configuration"), + ({"dry_run": "maybe"}, "invalid configuration"), + ], +) +def test_rejected_configurations(args, fragment): + merged = dict(BASE_ARGS) + merged.update(args) + with pytest.raises(nr.ConfigError) as excinfo: + nr._load_config(merged) + assert fragment in str(excinfo.value) + + +def test_tags_parse_into_a_filter_map(): + cfg = cfg_for(tags="site:A zone:north") + assert cfg["tags"] == {"site": "A", "zone": "north"} + + +def test_feature_fields_are_deduplicated_in_order(): + cfg = cfg_for(feature_fields="temp humidity temp") + assert cfg["feature_fields"] == ["temp", "humidity"] + + +def test_defaults(): + cfg = cfg_for() + assert cfg["model"] == nr.DEFAULT_MODEL_SLUG == "synthefy/nori-30m" + assert cfg["output_measurement"] == "sensors_regressed" + assert cfg["window"] == timedelta(days=30) + assert cfg["skip_existing"] is True + assert cfg["dry_run"] is False + assert cfg["target_database"] is None + # A cold start was measured at 125s on synthefy/nori-30m, so the default must clear it. + assert cfg["request_timeout"] >= 300 + + +# --------------------------------------------------------------------------- +# API key resolution +# --------------------------------------------------------------------------- + + +def test_header_key_wins_over_the_environment(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "from-env") + assert nr._get_api_key({"X-Nori-Api-Key": " from-header "}) == "from-header" + assert nr._get_api_key({"x-nori-api-key": "lowercased"}) == "lowercased" + + +def test_empty_header_value_falls_back_to_the_environment(monkeypatch): + """An empty header used to win and send `Api-Key ` with no key at all.""" + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "from-env") + assert nr._get_api_key({"X-Nori-Api-Key": ""}) == "from-env" + assert nr._get_api_key({"X-Nori-Api-Key": " "}) == "from-env" + + +def test_non_string_header_values_do_not_crash(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "from-env") + assert nr._get_api_key({"X-Nori-Api-Key": ["listed"]}) == "listed" + assert nr._get_api_key({"X-Nori-Api-Key": []}) == "from-env" + assert nr._get_api_key({"X-Nori-Api-Key": 1234}) == "from-env" + assert nr._get_api_key({b"binary": b"key"}) == "from-env" + + +def test_no_key_anywhere_is_a_config_error(): + with pytest.raises(nr.ConfigError, match=nr.API_KEY_ENV_VAR): + nr._get_api_key({}) + + +def test_authorization_header_is_never_read(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "from-env") + assert nr._get_api_key({"Authorization": "Api-Key sneaky"}) == "from-env" + + +# --------------------------------------------------------------------------- +# Gateway URL +# --------------------------------------------------------------------------- + + +def test_gateway_url_env_override_requires_https(monkeypatch): + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, "https://gateway.internal/predict") + assert nr._gateway_url() == "https://gateway.internal/predict" + + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, "http://attacker.example/predict") + with pytest.raises(nr.ConfigError, match="https"): + nr._gateway_url() + + +def test_gateway_url_allows_plain_http_on_loopback_only(monkeypatch): + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, "http://localhost:9000/predict") + assert nr._gateway_url() == "http://localhost:9000/predict" + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, "http://127.0.0.1:9000/predict") + assert nr._gateway_url().startswith("http://127.0.0.1") + + +@pytest.mark.parametrize( + "url", + [ + "http://localhost@evil.example/predict", # userinfo, not a loopback host + "http://[::1]@evil.example/predict", # urlsplit itself raises on this one + "http://127.0.0.1.evil.example/predict", + "http://localhost.evil.example/predict", + "http://evil.example/predict", + "http://127.0.0.2/predict", # not loopback + "http://0.0.0.0/predict", + "https:///predict", # no host + "http://", + "ftp://localhost/predict", + "file:///etc/passwd", + "//evil.example/predict", + "evil.example/predict", + "not a url", + ], +) +def test_gateway_url_rejects_a_disguised_host(monkeypatch, url): + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, url) + with pytest.raises(nr.ConfigError): + nr._gateway_url() + + +@pytest.mark.parametrize( + "url", + [ + "https://gateway.internal/predict", + " https://gateway.internal/predict ", # trimmed + "http://LOCALHOST/predict", # the host is matched case-insensitively + "http://[::1]:9000/predict", + "http://127.0.0.1:9000/predict?x=1", + ], +) +def test_gateway_url_accepts_the_legitimate_forms(monkeypatch, url): + monkeypatch.setenv(nr.GATEWAY_URL_ENV_VAR, url) + assert nr._gateway_url() == url.strip() + + +# --------------------------------------------------------------------------- +# Window resolution +# --------------------------------------------------------------------------- + + +NOW = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc) + + +def test_window_uses_both_bounds_when_given(): + cfg = cfg_for(start_time="2026-01-01T00:00:00Z", end_time="2026-02-01T00:00:00Z") + start, end = nr._resolve_window(cfg, NOW) + assert start == datetime(2026, 1, 1, tzinfo=timezone.utc) + assert end == datetime(2026, 2, 1, tzinfo=timezone.utc) + + +def test_start_time_alone_reads_up_to_now(): + """This used to fall back to the full 30-day window with no message.""" + cfg = cfg_for(start_time="2026-05-30T00:00:00Z") + start, end = nr._resolve_window(cfg, NOW) + assert start == datetime(2026, 5, 30, tzinfo=timezone.utc) + assert end == NOW + + +def test_end_time_alone_reads_one_window_before_it(): + cfg = cfg_for(end_time="2026-03-01T00:00:00Z", window="2d") + start, end = nr._resolve_window(cfg, NOW) + assert end == datetime(2026, 3, 1, tzinfo=timezone.utc) + assert start == datetime(2026, 2, 27, tzinfo=timezone.utc) + + +def test_no_bounds_uses_the_trailing_window(): + cfg = cfg_for(window="6h") + start, end = nr._resolve_window(cfg, NOW) + assert end == NOW + assert start == NOW - timedelta(hours=6) + + +def test_naive_iso_bounds_are_utc(): + cfg = cfg_for(start_time="2026-01-01T00:00:00", end_time="2026-01-02T00:00:00") + start, end = nr._resolve_window(cfg, NOW) + assert start.tzinfo is not None and end.tzinfo is not None + + +@pytest.mark.parametrize( + "args, fragment", + [ + ({"start_time": "not-a-date"}, "ISO 8601"), + ( + {"start_time": "2026-02-01T00:00:00Z", "end_time": "2026-01-01T00:00:00Z"}, + "empty time window", + ), + ( + {"start_time": "2026-01-01T00:00:00Z", "end_time": "2026-01-01T00:00:00Z"}, + "empty time window", + ), + ], +) +def test_rejected_windows(args, fragment): + with pytest.raises(nr.ConfigError, match=fragment): + nr._resolve_window(cfg_for(**args), NOW) + + +# --------------------------------------------------------------------------- +# WHERE clause +# --------------------------------------------------------------------------- + + +def test_the_read_query_is_bounded_by_max_read_rows(): + """The row caps bound what is SENT; without a LIMIT nothing bounds what is READ, and `window` + is settable from the request body.""" + data = labeled(60) + unlabeled(2) + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': data}) + cfg = cfg_for(max_read_rows="62") + schema = nr._resolve_schema(local, cfg) + rows = nr._read_rows(local, cfg, schema, NOW - timedelta(days=30), NOW, "t") + sql = [q for q in local.queries if "information_schema" not in q["sql"]][0]["sql"] + assert "ORDER BY time DESC LIMIT 62" in sql + # The DESC query keeps the newest rows; the ascending order downstream depends on is this + # function's own guarantee, not the engine's. + assert [r["time_ns"] for r in rows] == sorted(r["time_ns"] for r in rows) + assert any("at least max_read_rows (62)" in w for w in local.warns) + + +def test_rows_are_sorted_regardless_of_the_order_the_engine_returns(): + data = labeled(5) + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': list(reversed(data))} + ) + cfg = cfg_for() + rows = nr._read_rows( + local, cfg, nr._resolve_schema(local, cfg), NOW - timedelta(days=30), NOW, "t" + ) + assert [r["time_ns"] for r in rows] == sorted(r["time_ns"] for r in rows) + + +def test_a_read_under_the_ceiling_does_not_warn(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(10)}) + cfg = cfg_for() + nr._read_rows(local, cfg, nr._resolve_schema(local, cfg), NOW - timedelta(days=30), NOW, "t") + assert not any("max_read_rows" in w for w in local.warns) + + +def test_where_clause_binds_values_and_escapes_identifiers(): + clause, params = nr._build_where( + {'we"ird': "va'lue"}, NOW - timedelta(hours=1), NOW + ) + assert clause == 'time >= $start_ts AND time < $end_ts AND "we""ird" = $tag0' + assert params["tag0"] == "va'lue" + assert params["start_ts"] == "2026-06-01T11:00:00Z" + assert params["end_ts"] == "2026-06-01T12:00:00Z" + + +# --------------------------------------------------------------------------- +# Prediction validation +# --------------------------------------------------------------------------- + + +def test_valid_predictions_pass_through(): + assert nr._validate_predictions([1.0, 2, -3.5], 3) == [1.0, 2.0, -3.5] + + +def test_a_null_prediction_is_a_per_row_outcome(): + """The gateway emits JSON null for a row whose prediction is not finite.""" + assert nr._validate_predictions([1.0, None, 3.0], 3) == [1.0, None, 3.0] + + +@pytest.mark.parametrize( + "preds, expected, fragment", + [ + ([None, None], 2, "every one of the 2 predictions is null"), + ([1.0], 2, "returned 1 predictions for 2 rows"), + ({"predictions": []}, 1, "expected a list"), + (None, 1, "expected a list"), + (["1.5"], 1, "expected a number"), + ([True], 1, "expected a number"), + ([float("nan")], 1, "not finite"), + ([float("inf")], 1, "not finite"), + ], +) +def test_rejected_predictions(preds, expected, fragment): + with pytest.raises(nr.GatewayError, match=fragment): + nr._validate_predictions(preds, expected) + + +# --------------------------------------------------------------------------- +# Schema resolution +# --------------------------------------------------------------------------- + + +def test_schema_resolution_returns_tag_names(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + schema = nr._resolve_schema(local, cfg_for()) + assert schema["tag_names"] == ["site"] + assert local.queries[0]["args"] == {"m": "sensors"} + + +@pytest.mark.parametrize( + "args, fragment", + [ + ({"feature_fields": "temp status"}, "is Utf8, not a numeric field"), + ({"feature_fields": "temp site"}, "not a numeric field"), + ({"feature_fields": "temp nope"}, "not found in 'sensors'"), + ({"field": "status"}, "target field 'status' is Utf8"), + ({"field": "nope"}, "target field 'nope' not found"), + ({"tags": "nope:x"}, "tag column(s) ['nope'] not found"), + ({"tags": "temp:1"}, "names field column(s) ['temp'], not tags"), + ], +) +def test_schema_rejects_columns_it_cannot_serve(args, fragment): + """A name-only check let a tag or string column through, then reported 'only 0 labeled rows'.""" + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + with pytest.raises(nr.ConfigError) as excinfo: + nr._resolve_schema(local, cfg_for(**args)) + assert fragment in str(excinfo.value) + + +@pytest.mark.parametrize("clash", sorted(nr.PROVENANCE_TAGS)) +def test_a_source_tag_clashing_with_a_provenance_tag_is_rejected(clash): + """Such a tag would overwrite the provenance on write AND make the skip_existing lookup + self-contradictory, so the run would re-pay for the same rows forever.""" + schema = SENSOR_SCHEMA + [{"column_name": clash, "data_type": nr.TAG_DATA_TYPE}] + local = FakeLocal({"information_schema": schema}) + with pytest.raises(nr.ConfigError, match="collide with the provenance tags"): + nr._resolve_schema(local, cfg_for()) + + +def test_a_source_tag_named_y_does_not_break_the_query(): + """The target is not aliased to `y`, so a tag column of that name cannot collide with it.""" + schema = SENSOR_SCHEMA + [{"column_name": "y", "data_type": nr.TAG_DATA_TYPE}] + data = [{**r, "y": "tagvalue"} for r in labeled(60) + unlabeled(1)] + local = FakeLocal({"information_schema": schema, 'FROM "sensors"': data}) + cfg = cfg_for() + resolved = nr._resolve_schema(local, cfg) + assert "y" in resolved["tag_names"] + read = nr._read_rows(local, cfg, resolved, NOW - timedelta(days=30), NOW, "t") + sql = [q for q in local.queries if "information_schema" not in q["sql"]][0]["sql"] + assert " AS y" not in sql + assert read[0]["y"] == 1000.0 # the target value, read under its own column name + assert ("y", "tagvalue") in read[0]["series"] + + +def test_unknown_measurement_is_reported_plainly(): + local = FakeLocal({"information_schema": []}) + with pytest.raises(nr.ConfigError, match="not found \\(no columns\\)"): + nr._resolve_schema(local, cfg_for()) + + +# --------------------------------------------------------------------------- +# Gateway transport +# --------------------------------------------------------------------------- + + +def _call(local, cfg, script, api_key="k"): + fake = FakeRequests(script) + original = nr.requests + nr.requests = fake + try: + return nr._call_nori(local, cfg, [[1.0, 2.0]], [3.0], [[1.0, 2.0]], api_key, "t"), fake + finally: + nr.requests = original + + +def test_gateway_call_sends_the_documented_payload(): + local = FakeLocal() + preds, fake = _call( + local, cfg_for(), [FakeResponse(200, {"predictions": [9.5], "usage": {}})] + ) + assert preds == [9.5] + sent = fake.calls[0] + assert sent["url"] == nr.DEFAULT_GATEWAY_URL + assert sent["json"]["model"] == "synthefy/nori-30m" + assert sent["json"]["task"] == "regression" + assert sent["headers"]["Authorization"] == "Api-Key k" + assert sent["timeout"] >= 300 + + +def test_a_transient_fault_is_retried(): + local = FakeLocal() + preds, fake = _call( + local, + cfg_for(max_retries="3"), + [ + FakeResponse(503, {"detail": "model loading"}), + FakeResponse(429, {"detail": "slow down"}), + FakeResponse(200, {"predictions": [1.0]}), + ], + ) + assert preds == [1.0] + assert len(fake.calls) == 3 + assert any("attempt 1 failed (HTTP 503)" in w for w in local.warns) + + +def test_retry_after_is_honoured(monkeypatch): + slept = [] + monkeypatch.setattr(nr.time, "sleep", lambda s: slept.append(s)) + local = FakeLocal() + _call( + local, + cfg_for(), + [ + FakeResponse(429, {"detail": "slow"}, headers={"Retry-After": "7"}), + FakeResponse(200, {"predictions": [1.0]}), + ], + ) + assert slept == [7.0] + + +def test_a_negative_retry_after_does_not_abort_the_run(monkeypatch): + """time.sleep raises on a negative delay, which would throw away healthy attempts.""" + slept = [] + monkeypatch.setattr(nr.time, "sleep", lambda s: slept.append(s)) + local = FakeLocal() + preds, _ = _call( + local, + cfg_for(), + [ + FakeResponse(503, {"detail": "x"}, headers={"Retry-After": "-5"}), + FakeResponse(200, {"predictions": [1.0]}), + ], + ) + assert preds == [1.0] + assert slept == [0.0] + + +def test_an_http_date_retry_after_falls_back_to_backoff(monkeypatch): + slept = [] + monkeypatch.setattr(nr.time, "sleep", lambda s: slept.append(s)) + local = FakeLocal() + _call( + local, + cfg_for(), + [ + FakeResponse(429, {}, headers={"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}), + FakeResponse(200, {"predictions": [1.0]}), + ], + ) + assert 1.0 <= slept[0] <= 2.0 + + +def test_backoff_is_capped(monkeypatch): + slept = [] + monkeypatch.setattr(nr.time, "sleep", lambda s: slept.append(s)) + local = FakeLocal() + _call( + local, + cfg_for(), + [ + FakeResponse(503, {"detail": "x"}, headers={"Retry-After": "9999"}), + FakeResponse(200, {"predictions": [1.0]}), + ], + ) + assert slept == [nr.MAX_BACKOFF_SECONDS] + + +@pytest.mark.parametrize("status", [400, 403, 404, 413, 422]) +def test_a_permanent_fault_is_not_retried(status): + local = FakeLocal() + with pytest.raises(nr.GatewayError) as excinfo: + _call(local, cfg_for(), [FakeResponse(status, {"error": "nope"})]) + assert f"HTTP {status}" in str(excinfo.value) + # The gateway's echoed body goes to the log, not to the caller: a private NORI_GATEWAY_URL can + # name its own host in it. The endpoint never appears either. + assert "nope" not in str(excinfo.value) + assert "nope" in excinfo.value.log_text() + assert nr.DEFAULT_GATEWAY_URL not in excinfo.value.log_text() + + +def test_a_read_timeout_is_not_retried(): + """It has already spent the whole budget, and usually means an ungranted slug.""" + import requests as real_requests + + local = FakeLocal() + with pytest.raises(nr.GatewayError, match="did not respond within"): + _call(local, cfg_for(), [real_requests.exceptions.ReadTimeout("timed out")]) + + +def test_a_connection_error_is_retried(): + import requests as real_requests + + local = FakeLocal() + preds, fake = _call( + local, + cfg_for(), + [ + real_requests.exceptions.ConnectionError("reset"), + FakeResponse(200, {"predictions": [2.0]}), + ], + ) + assert preds == [2.0] + assert len(fake.calls) == 2 + + +def test_repeated_connection_errors_exhaust_the_attempts(): + import requests as real_requests + + local = FakeLocal() + with pytest.raises(nr.GatewayError, match="could not reach the Nori gateway after 2"): + _call( + local, + cfg_for(max_retries="2"), + [real_requests.exceptions.ConnectionError("reset")] * 2, + ) + + +def test_a_non_json_200_blames_the_gateway_not_the_caller(): + """A proxy in front of a scaled-to-zero model can answer 200 with an HTML page.""" + local = FakeLocal() + with pytest.raises(nr.GatewayError, match="non-JSON body"): + _call( + local, + cfg_for(), + [FakeResponse(200, None, text="502", headers={"Content-Type": "text/html"})], + ) + + +def test_gateway_error_text_survives_a_non_json_error_body(): + local = FakeLocal() + with pytest.raises(nr.GatewayError) as excinfo: + _call( + local, + cfg_for(max_retries="1"), + [FakeResponse(502, None, text="Bad Gateway", headers={})], + ) + assert "Bad Gateway" in excinfo.value.log_text() + + +# --------------------------------------------------------------------------- +# Regression flow +# --------------------------------------------------------------------------- + + +def _regress(local, cfg, script, api_key="k"): + fake = FakeRequests(script) + original = nr.requests + nr.requests = fake + try: + schema = nr._resolve_schema(local, cfg) + return ( + nr._regress( + local, cfg, schema, NOW - timedelta(days=30), NOW, api_key, "t" + ), + fake, + ) + finally: + nr.requests = original + + +def test_two_series_in_the_window_fail_loud(): + """The old guard only checked duplicate timestamps, so disjoint series trained as one.""" + data = labeled(60, site="A") + labeled(60, start=5000, site="B") + unlabeled(2, site="A") + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': data}) + with pytest.raises(nr.ConfigError) as excinfo: + _regress(local, cfg_for(), []) + message = str(excinfo.value) + assert "holds 2 series" in message + assert "site=A" in message and "site=B" in message + + +def test_a_tags_filter_isolates_one_series(): + data = labeled(60, site="A") + unlabeled(2, site="A") + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': data}) + (times, preds, series, _unf), fake = _regress( + local, cfg_for(tags="site:A"), [FakeResponse(200, {"predictions": [1.0, 2.0]})] + ) + assert len(preds) == 2 + assert series == {"site": "A"} + assert fake.calls[0]["json"]["X_train"][0] == [20.0, 40.0] + + +def test_too_little_history_skips_without_calling_the_gateway(): + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(10) + unlabeled(2)} + ) + (times, preds, _, _unf), fake = _regress(local, cfg_for(), []) + assert (times, preds) == ([], []) + assert fake.calls == [] + assert any("only 10 labeled rows" in w for w in local.warns) + + +def test_nothing_to_predict_skips_without_calling_the_gateway(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60)}) + (times, preds, _, _unf), fake = _regress(local, cfg_for(), []) + assert (times, preds) == ([], []) + assert fake.calls == [] + assert any("no rows to predict" in w for w in local.warns) + + +def test_rows_missing_a_feature_are_dropped(): + data = labeled(60) + [ + {"time": 99_000 * NS, "pressure": None, "temp": 21.0, "humidity": None, "site": "A"} + ] + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': data}) + (times, preds, _, _unf), fake = _regress(local, cfg_for(), []) + assert fake.calls == [] + + +def test_non_numeric_and_non_finite_values_never_reach_the_gateway(): + data = labeled(60) + rows([(20_000, None, 21.0, 41.0)]) + data[0]["temp"] = float("inf") # dropped: a feature must be finite + data[1]["pressure"] = "not a number" # dropped from training, and not a prediction target + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': data}) + (times, preds, _, _unf), fake = _regress( + local, cfg_for(min_history="1"), [FakeResponse(200, {"predictions": [1.0, 2.0]})] + ) + sent = fake.calls[0]["json"] + assert all(all(math.isfinite(v) for v in row) for row in sent["X_train"]) + assert all(math.isfinite(v) for v in sent["y_train"]) + # 60 labeled rows, less the one with a non-finite feature (dropped entirely) and the one whose + # target will not parse (a prediction target instead of training data). + assert len(sent["X_train"]) == 58 + assert len(sent["X_test"]) == 2 + + +def test_skip_existing_drops_rows_that_already_hold_a_prediction(): + data = labeled(60) + unlabeled(3) + already = [{"time": 10_000 * NS}, {"time": 10_001 * NS}] + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': data, + 'FROM "sensors_regressed"': already, + } + ) + (times, preds, _, _unf), fake = _regress( + local, cfg_for(), [FakeResponse(200, {"predictions": [7.0]})] + ) + assert times == [10_002 * NS] + assert len(fake.calls[0]["json"]["X_test"]) == 1 + lookup = [q for q in local.queries if "sensors_regressed" in q["sql"]][0] + assert lookup["args"]["src"] == "sensors" + assert lookup["args"]["tgt"] == "pressure" + + +def test_skip_existing_filters_by_series_so_a_sibling_trigger_cannot_interfere(): + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': labeled(60) + unlabeled(1), + 'FROM "sensors_regressed"': [], + } + ) + _regress(local, cfg_for(), [FakeResponse(200, {"predictions": [1.0]})]) + lookup = [q for q in local.queries if "sensors_regressed" in q["sql"]][0] + assert '"site" = $tag0' in lookup["sql"] + assert lookup["args"]["tag0"] == "A" + + +def test_all_rows_already_predicted_is_a_no_op(): + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': labeled(60) + unlabeled(2), + 'FROM "sensors_regressed"': [{"time": 10_000 * NS}, {"time": 10_001 * NS}], + } + ) + (times, preds, _, _unf), fake = _regress(local, cfg_for(), []) + assert (times, preds) == ([], []) + assert fake.calls == [] + + +def test_skip_existing_false_re_predicts(): + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': labeled(60) + unlabeled(2), + 'FROM "sensors_regressed"': [{"time": 10_000 * NS}, {"time": 10_001 * NS}], + } + ) + (times, preds, _, _unf), fake = _regress( + local, cfg_for(skip_existing="false"), [FakeResponse(200, {"predictions": [1.0, 2.0]})] + ) + assert len(preds) == 2 + assert not any("sensors_regressed" in q["sql"] for q in local.queries) + + +def test_a_missing_output_measurement_is_not_an_error(): + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': labeled(60) + unlabeled(1), + 'FROM "sensors_regressed"': RuntimeError("table not found"), + } + ) + (times, preds, _, _unf), fake = _regress( + local, cfg_for(), [FakeResponse(200, {"predictions": [1.0]})] + ) + assert len(preds) == 1 + + +def test_row_caps_keep_the_most_recent_rows(): + """The query has no LIMIT, so a wide window on a fast series must not build one huge payload.""" + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(200) + unlabeled(50)} + ) + (times, preds, _, _unf), fake = _regress( + local, + cfg_for(max_train_rows="20", max_predict_rows="5", min_history="10"), + [FakeResponse(200, {"predictions": [1.0] * 5})], + ) + sent = fake.calls[0]["json"] + assert len(sent["X_train"]) == 20 + assert len(sent["X_test"]) == 5 + assert sent["y_train"][-1] == 1000.0 + 199 # the newest labeled row + assert times == [(10_000 + i) * NS for i in range(45, 50)] # the newest unlabeled rows + assert any("exceed max_train_rows" in w for w in local.warns) + assert any("exceed max_predict_rows" in w for w in local.warns) + + +def test_a_failed_later_batch_keeps_the_batches_already_paid_for(): + """Discarding them would make the next run buy the same predictions a second time.""" + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(6)} + ) + (times, preds, _, _unf), fake = _regress( + local, + cfg_for(predict_batch_size="2", max_retries="1"), + [ + FakeResponse(200, {"predictions": [1.0, 2.0]}), + FakeResponse(404, {"error": "gone"}), + ], + ) + assert preds == [1.0, 2.0] + assert len(times) == 2 + assert any("keeping the 2 predictions already returned" in w for w in local.warns) + + +def test_a_partial_run_is_not_reported_as_success(): + """8 of 20 rows written after a mid-run gateway fault is not "success" to a caller who cannot + read the log.""" + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(6)} + ) + result, _ = _http( + local, + {}, + args={**BASE_ARGS, "predict_batch_size": "2", "max_retries": "1"}, + script=[ + FakeResponse(200, {"predictions": [1.0, 2.0]}), + FakeResponse(404, {"error": "gone"}), + ], + ) + assert result["status"] == "partial" + assert result["result"]["written"] == 2 + assert result["result"]["remaining"] == 4 + + +def test_a_failed_first_batch_still_raises(): + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(4)} + ) + with pytest.raises(nr.GatewayError): + _regress( + local, + cfg_for(predict_batch_size="2", max_retries="1"), + [FakeResponse(404, {"error": "gone"})], + ) + + +def test_batching_reuses_one_training_context_per_call(): + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(5)} + ) + (times, preds, _, _unf), fake = _regress( + local, + cfg_for(predict_batch_size="2"), + [ + FakeResponse(200, {"predictions": [1.0, 2.0]}), + FakeResponse(200, {"predictions": [3.0, 4.0]}), + FakeResponse(200, {"predictions": [5.0]}), + ], + ) + assert preds == [1.0, 2.0, 3.0, 4.0, 5.0] + assert len(times) == 5 + assert [len(c["json"]["X_test"]) for c in fake.calls] == [2, 2, 1] + # Every batch re-sends the context and is billed again, so the log has to say so. + assert all(len(c["json"]["X_train"]) == 60 for c in fake.calls) + assert any("billed separately" in i for i in local.infos) + + +# --------------------------------------------------------------------------- +# Writing +# --------------------------------------------------------------------------- + + +def test_predictions_are_written_with_the_source_series_tags(): + local = FakeLocal() + written = nr._write_predictions( + local, cfg_for(), [1_000 * NS, 2_000 * NS], [1.5, 2.5], {"site": "A"}, "t" + ) + assert written == 2 + database, payload = local.writes[0] + assert database is None + lines = payload.split("\n") + assert lines[0].startswith("sensors_regressed,") + for expected in ("model=synthefy/nori-30m", "source=sensors", "target=pressure", "site=A"): + assert expected in lines[0] + assert lines[0].endswith(" value=1.5 1000000000000") + + +def test_a_null_prediction_is_skipped_and_reported(): + local = FakeLocal() + written = nr._write_predictions( + local, cfg_for(), [1_000 * NS, 2_000 * NS], [None, 2.5], {}, "t" + ) + assert written == 1 + assert any("had no finite prediction" in w for w in local.warns) + + +def test_writes_can_target_another_database(): + local = FakeLocal() + nr._write_predictions( + local, cfg_for(target_database="preds"), [1_000 * NS], [1.0], {}, "t" + ) + assert local.writes[0][0] == "preds" + + +def test_a_failing_write_raises_after_the_configured_attempts(): + local = FakeLocal() + local.fail_writes = True + with pytest.raises(RuntimeError, match="write refused"): + nr._write_predictions( + local, cfg_for(max_retries="2"), [1_000 * NS], [1.0], {}, "t" + ) + + +# --------------------------------------------------------------------------- +# Entry points +# --------------------------------------------------------------------------- + + +def _http(local, body, args=None, headers=None, script=None): + """Drive process_request. By default the trigger carries no arguments and the body supplies + everything, which is the documented on-demand shape (a trigger argument pins its value).""" + if args is None: + if isinstance(body, dict): + body = {**BASE_ARGS, **body} + args = {} + fake = FakeRequests(script or []) + original = nr.requests + nr.requests = fake + try: + return ( + nr.process_request( + local, + {}, + headers if headers is not None else {"X-Nori-Api-Key": "k"}, + body if isinstance(body, (str, bytes)) or body is None else json.dumps(body), + args, + ), + fake, + ) + finally: + nr.requests = original + + +def test_http_success_reports_the_real_outcome(): + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(2)} + ) + result, _ = _http(local, {}, script=[FakeResponse(200, {"predictions": [1.0, 2.0]})]) + assert result["status"] == "success" + assert result["result"]["written"] == 2 + assert result["task_id"] + + +def test_http_skipped_is_not_reported_as_success(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60)}) + result, _ = _http(local, {}) + assert result["status"] == "skipped" + + +def test_http_dry_run_does_not_write(): + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(1)} + ) + result, _ = _http( + local, {"dry_run": True}, script=[FakeResponse(200, {"predictions": [1.0]})] + ) + assert result["status"] == "dry_run" + assert local.writes == [] + + +@pytest.mark.parametrize("body", ["{not json", b"\xff\xfe", "[1,2,3]", '"a string"']) +def test_a_bad_request_body_is_reported_as_such(body): + local = FakeLocal() + result, _ = _http(local, body) + assert result["status"] == "failed" + assert "body" in result["message"] + + +@pytest.mark.parametrize("body", [12345, 3.5, ["a"], object()]) +def test_an_unexpected_body_type_stays_inside_the_error_contract(body): + """An unguarded len() would raise TypeError straight out of process_request.""" + local = FakeLocal() + result = nr.process_request(local, {}, {"X-Nori-Api-Key": "k"}, body, dict(BASE_ARGS)) + assert result["status"] == "failed" + assert "request body must be JSON text" in result["message"] + + +def test_rfc3339_treats_a_naive_datetime_as_utc(): + """astimezone() on a naive value would otherwise read the host's local timezone.""" + assert nr._rfc3339(datetime(2026, 6, 1, 12, 0)) == "2026-06-01T12:00:00Z" + + +def test_a_body_override_attempt_is_reported_to_the_caller(): + local = FakeLocal() + result, _ = _http(local, {"gateway_url": "https://attacker.example/x"}) + assert result["status"] == "failed" + assert "gateway_url" in result["message"] + assert local.writes == [] + + +def test_a_config_error_message_reaches_the_caller(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + result, _ = _http(local, {"field": "status"}) + assert result["status"] == "failed" + assert "not a numeric field" in result["message"] + + +def test_an_unexpected_error_does_not_leak_internal_detail(): + """A storage error can name the target database and the endpoint, so it stays in the log.""" + local = FakeLocal( + { + "information_schema": SENSOR_SCHEMA, + 'FROM "sensors"': labeled(60) + unlabeled(1), + } + ) + local.fail_writes = True + result, _ = _http( + local, + {}, + args={**BASE_ARGS, "target_database": "secret_db", "max_retries": "1"}, + script=[FakeResponse(200, {"predictions": [1.0]})], + ) + assert result["status"] == "failed" + assert result["message"] == "internal error; see the plugin logs for this task_id" + assert "secret_db" not in result["message"] + assert any("secret_db" in e or "write refused" in e for e in local.errors) + + +def test_a_missing_api_key_is_reported_before_any_gateway_call(): + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + result, fake = _http(local, {}, headers={}) + assert result["status"] == "failed" + assert nr.API_KEY_ENV_VAR in result["message"] + assert fake.calls == [] + + +def test_scheduled_run_writes_predictions(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "k") + local = FakeLocal( + {"information_schema": SENSOR_SCHEMA, 'FROM "sensors"': labeled(60) + unlabeled(2)} + ) + fake = FakeRequests([FakeResponse(200, {"predictions": [1.0, 2.0]})]) + original = nr.requests + nr.requests = fake + try: + nr.process_scheduled_call(local, datetime(2026, 6, 1, 12, 0), dict(BASE_ARGS)) + finally: + nr.requests = original + assert local.errors == [] + assert len(local.writes) == 1 + + +def test_scheduled_run_anchors_the_window_to_call_time(monkeypatch): + monkeypatch.setenv(nr.API_KEY_ENV_VAR, "k") + local = FakeLocal({"information_schema": SENSOR_SCHEMA}) + nr.process_scheduled_call( + local, datetime(2026, 6, 1, 12, 0), {**BASE_ARGS, "window": "1h"} + ) + data_query = [q for q in local.queries if "information_schema" not in q["sql"]][0] + assert data_query["args"]["start_ts"] == "2026-06-01T11:00:00Z" + assert data_query["args"]["end_ts"] == "2026-06-01T12:00:00Z" + + +def test_a_scheduled_failure_is_logged_not_raised(): + local = FakeLocal() + nr.process_scheduled_call(local, datetime(2026, 6, 1, 12, 0), {"measurement": "sensors"}) + assert local.errors and "ConfigError" in local.errors[0]