From f222be63b2261143174975eb85c08e30ba3e4f94 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:30 +0200 Subject: [PATCH] Release govoplan-dataflow v0.1.24: bound preview allocation and restore editor imports --- pyproject.toml | 4 +- src/govoplan_dataflow/__init__.py | 2 +- src/govoplan_dataflow/backend/executor.py | 2 +- src/govoplan_dataflow/backend/expressions.py | 10 ++ .../backend/german_documentation.py | 12 +- src/govoplan_dataflow/backend/manifest.py | 33 +++- .../backend/preview_limits.py | 3 + tests/test_padding_budget.py | 165 ++++++++++++++++++ webui/package.json | 4 +- webui/src/features/dataflow/DataflowPage.tsx | 30 ++-- 10 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 src/govoplan_dataflow/backend/preview_limits.py create mode 100644 tests/test_padding_budget.py diff --git a/pyproject.toml b/pyproject.toml index 90e52e1..d359928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-dataflow" -version = "0.1.23" +version = "0.1.24" description = "Governed graphical and SQL data pipelines for GovOPlaN." readme = "README.md" requires-python = ">=3.12" license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.45", "sqlglot>=30.14,<31", ] diff --git a/src/govoplan_dataflow/__init__.py b/src/govoplan_dataflow/__init__.py index c35fe37..2128b18 100644 --- a/src/govoplan_dataflow/__init__.py +++ b/src/govoplan_dataflow/__init__.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "0.1.23" +__version__ = "0.1.24" diff --git a/src/govoplan_dataflow/backend/executor.py b/src/govoplan_dataflow/backend/executor.py index 25a00ba..86c2a39 100644 --- a/src/govoplan_dataflow/backend/executor.py +++ b/src/govoplan_dataflow/backend/executor.py @@ -19,6 +19,7 @@ from govoplan_dataflow.backend.operator_registry import ( OperatorExecutionContext, OperatorExecutionResult, ) +from govoplan_dataflow.backend.preview_limits import MAX_RESULT_BYTES from govoplan_dataflow.backend.schemas import ( DataflowDiagnostic, GraphNode, @@ -32,7 +33,6 @@ from govoplan_dataflow.backend.subflows import substitute_parameters EXECUTOR_VERSION = "dataflow-preview-v2" MAX_EXECUTION_SECONDS = 2.0 -MAX_RESULT_BYTES = 1_000_000 MAX_SOURCE_ROWS = 250 MAX_INTERMEDIATE_ROWS = 10_000 diff --git a/src/govoplan_dataflow/backend/expressions.py b/src/govoplan_dataflow/backend/expressions.py index f45d75b..dd6c007 100644 --- a/src/govoplan_dataflow/backend/expressions.py +++ b/src/govoplan_dataflow/backend/expressions.py @@ -12,6 +12,8 @@ import sqlglot from sqlglot import exp from sqlglot.errors import ParseError +from govoplan_dataflow.backend.preview_limits import MAX_RESULT_BYTES + ExpressionDataType = Literal[ "unknown", @@ -438,6 +440,14 @@ def _evaluate_pad(expression: exp.Expression, row: dict[str, Any]) -> str | None target_length = int(_evaluate(expression.expression, row)) if target_length < 0: raise ValueError("Padding length cannot be negative.") + # Every character takes at least one serialized byte. Enforce the existing + # node budget before padding allocates memory, including when an outer + # LENGTH/SUBSTRING would otherwise conceal the oversized intermediate value. + # The node's final byte check still accounts for Unicode and JSON overhead. + if target_length > MAX_RESULT_BYTES: + raise ExpressionError( + f"Padding length exceeds the {MAX_RESULT_BYTES:,}-byte preview result limit." + ) source = str(value) if len(source) >= target_length: return source[:target_length] diff --git a/src/govoplan_dataflow/backend/german_documentation.py b/src/govoplan_dataflow/backend/german_documentation.py index a405406..10e0133 100644 --- a/src/govoplan_dataflow/backend/german_documentation.py +++ b/src/govoplan_dataflow/backend/german_documentation.py @@ -18,14 +18,22 @@ _TRANSLATIONS = { "title": "Modulgrenze von Dataflow", "summary": "Versionierte tabellarische Transformationen grafisch oder mit eingeschränktem SQL bearbeiten.", "body": ( - "Dataflow verantwortet kanonische Pipeline-Graphen, unveränderliche Revisionen, Validierung, eingeschränkte SQL-Kompilierung, Vorschau- und Laufdiagnosen sowie Herkunftsreferenzen. Datasources verantwortet den gesteuerten Katalog und Materialisierungen, Connectors den externen Abruf und Zugangsdaten, Reporting die analytische Darstellung und Exporte, Workflow die Orchestrierung und menschliche Übergaben und Risk Compliance die Sanktionsprüfung und Richtliniengrenzen. Benutzer-SQL wird in freigegebene Transformationen übersetzt und nie ungeprüft an eine Datenbank weitergereicht." + "Dataflow verantwortet kanonische Pipeline-Graphen, unveränderliche Revisionen, Validierung, eingeschränkte SQL-Kompilierung, Vorschau- und Laufdiagnosen sowie Herkunftsreferenzen. Datasources verantwortet den gesteuerten Katalog und Materialisierungen, Connectors den externen Abruf und Zugangsdaten, Reporting die analytische Darstellung und Exporte, Workflow die Orchestrierung und menschliche Übergaben und Risk Compliance die Sanktionsprüfung und Richtliniengrenzen. Benutzer-SQL wird in freigegebene Transformationen übersetzt und nie ungeprüft an eine Datenbank weitergereicht. " + "Das Öffnen oder Neuladen von Dataflow startet keine Pipeline. Kann der Editor nach einer Entwicklungsaktualisierung nicht geladen werden, sichern Sie ungespeicherte Arbeit vor dem Neuladen des Browsers. Administratoren sollten Fehler beim Laden von Oberflächendateien von Fehlern der Pipeline-API oder Zugriffsfehlern unterscheiden. Entwicklungs- und Browser-Konformitätsserver verwenden getrennte Abhängigkeitscaches; ein älterer Server muss nach dieser Konfigurationsaktualisierung gegebenenfalls neu gestartet werden." ), }, "dataflow.reference.nodes-and-expressions": { "title": "Dataflow-Knoten und Ausdrücke", "summary": "Typisierte Knoteneingaben, Ausdrücke, Schemafortschreibung und begrenzte Zwischenergebnisse verstehen.", "body": ( - "Jeder Graphknoten definiert typisierte Eingaben, Konfiguration, Ausgabeschema und Validierungsregeln. Quellknoten binden Inline-Inhalte oder gesteuerte Datasource-Referenzen; Verknüpfungs-, Filter-, Transformations-, Qualitäts-, Abgleich-, Teilfluss- und Ausgabeknoten bleiben im kanonischen Graphen ausdrücklich sichtbar. Wiederverwendbare Teilflüsse wählen eine durch Policy erlaubte unveränderliche Fluss- oder Vorlagenrevision. Der Server löst Graph, Quell-Hash, Policy-Entscheidung und geschlossene Ein-/Ausgabeverträge auf und bindet sie; mitgelieferte Graphkopien werden ignoriert, unvereinbare Eingaben und zyklische Referenzen abgelehnt. Abgleichzeilen führen stabile Schlüssel- und Eingabe-Hashes sowie Vorher-/Nachher-Werte. Prüfentscheidungen werden als unveränderliche, mandanteneigene Entscheidungssätze gespeichert; geänderte Eingaben werden ungültig, ohne Fachdaten still umzuschreiben. Ausdrücke führen weder Host- noch Datenbankcode aus. Knoten-Vorschauen sind begrenzt, für die handelnde Person datenschutzgefiltert und werden nicht als Laufergebnis gespeichert. SQL wird in denselben Graphen kompiliert; nicht unterstützte Anweisungen erscheinen als Diagnose." + "Jeder Graphknoten definiert typisierte Eingaben, Konfiguration, Ausgabeschema und Validierungsregeln. Quellknoten binden Inline-Inhalte oder gesteuerte Datasource-Referenzen; Verknüpfungs-, Filter-, Transformations-, Qualitäts-, Abgleich-, Teilfluss- und Ausgabeknoten bleiben im kanonischen Graphen ausdrücklich sichtbar. Wiederverwendbare Teilflüsse wählen eine durch Policy erlaubte unveränderliche Fluss- oder Vorlagenrevision. Der Server löst Graph, Quell-Hash, Policy-Entscheidung und geschlossene Ein-/Ausgabeverträge auf und bindet sie; mitgelieferte Graphkopien werden ignoriert, unvereinbare Eingaben und zyklische Referenzen abgelehnt. Abgleichzeilen führen stabile Schlüssel- und Eingabe-Hashes sowie Vorher-/Nachher-Werte. Prüfentscheidungen werden als unveränderliche, mandanteneigene Entscheidungssätze gespeichert; geänderte Eingaben werden ungültig, ohne Fachdaten still umzuschreiben. Ausdrücke führen weder Host- noch Datenbankcode aus. Knoten-Vorschauen sind begrenzt, für die handelnde Person datenschutzgefiltert und werden nicht als Laufergebnis gespeichert. SQL wird in denselben Graphen kompiliert; nicht unterstützte Anweisungen erscheinen als Diagnose. " + "Referenz-Vorschauen behalten die bestehende Grenze von 1.000.000 Byte je serialisiertem Knotenergebnis. " + "LPAD und RPAD weisen Ziellängen über 1.000.000 Zeichen vor dem Reservieren des Auffüllspeichers zurück. " + "Dies gilt auch für übergroße Zwischenergebnisse innerhalb von LENGTH oder SUBSTRING, selbst wenn der endgültige Einzelwert klein wäre. " + "Verringern Sie die gewünschte Auffülllänge; der Ausdruck scheitert mit einer Diagnose am betreffenden Knoten, statt Daten abzuschneiden. " + "Gewöhnliches Unicode-Auffüllen, NULL-Eingaben und Kürzungen innerhalb der Grenze behalten ihr Verhalten. " + "Die abschließende Byteprüfung berücksichtigt weiterhin JSON- und Mehrbyte-Zeichenaufwand. " + "Diese Speicherprüfung ersetzt keine Laufzeit- oder Bytegrenzen für andere Ausdrucksoperationen." ), }, "dataflow.reference.fields-and-consequences": { diff --git a/src/govoplan_dataflow/backend/manifest.py b/src/govoplan_dataflow/backend/manifest.py index 907f256..9b54d79 100644 --- a/src/govoplan_dataflow/backend/manifest.py +++ b/src/govoplan_dataflow/backend/manifest.py @@ -63,7 +63,7 @@ from govoplan_dataflow.backend.german_documentation import ( MODULE_ID = "dataflow" MODULE_NAME = "Dataflow" -MODULE_VERSION = "0.1.23" +MODULE_VERSION = "0.1.24" READ_SCOPE = "dataflow:pipeline:read" WRITE_SCOPE = "dataflow:pipeline:write" @@ -154,6 +154,21 @@ ROLE_TEMPLATES = ( ) DOCUMENTATION = localize_documentation_topics(( + DocumentationTopic( + id="dataflow.workspace-layout", + title="Dataflow workspace actions", + summary="Find collection-wide commands in their consistent workspace position.", + body="Reload and New pipeline use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. SQL editing, validation, previews, saving, and execution keep their existing editor scope and bounded safety rules. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.", + layer="static", + documentation_types=("user", "admin"), + audience=("user", "module_admin", "operator"), + order=5, + translations={"de": { + "title": "Datenflüsse: Aktionen im Arbeitsbereich", + "summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.", + "body": "Neu laden und Neue Pipeline stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. SQL-Bearbeitung, Validierung, Vorschau, Speichern und Ausführung behalten ihren bisherigen Editorbereich und ihre begrenzenden Sicherheitsregeln. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.", + }}, + ), DocumentationTopic( id="dataflow.data-subject-requests", title="Dataflow data-subject requests", @@ -186,7 +201,12 @@ DOCUMENTATION = localize_documentation_topics(( "and credentials; Reporting owns analytical presentation and exports; " "Workflow owns orchestration and human handoffs; Risk Compliance owns sanctions review " "semantics and policy gates. User SQL is compiled into approved transforms and is never " - "passed unchecked to a backing database." + "passed unchecked to a backing database. Opening or reloading Dataflow does not " + "start a pipeline. If the editor cannot be loaded after a development update, " + "preserve unsaved work before reloading the browser. Administrators should " + "distinguish frontend asset failures from pipeline API or access errors. " + "Development and browser-conformance servers use separate dependency caches; " + "an older server may need restarting after this configuration update." ), layer="available", documentation_types=("admin", "user"), @@ -280,7 +300,14 @@ DOCUMENTATION = localize_documentation_topics(( "Expressions use the typed Dataflow expression language and never execute arbitrary host or database " "code. Selecting a node may request a bounded intermediate preview; preview rows are transient, " "privacy-filtered for the actor, and are not retained as run output. SQL editing compiles into the same " - "canonical graph, so unsupported statements are diagnostics rather than pass-through SQL." + "canonical graph, so unsupported statements are diagnostics rather than pass-through SQL. " + "Reference previews retain the existing 1,000,000-byte serialized result limit per node. " + "LPAD and RPAD reject target lengths above 1,000,000 characters before allocating padding, " + "including oversized intermediate values inside LENGTH or SUBSTRING even if the final scalar would be small. " + "Reduce the requested padding length; this fails the expression at its node instead of truncating data. " + "Ordinary Unicode padding, null inputs, and in-budget truncation keep their existing behavior; " + "the final byte check still accounts for JSON and multibyte character overhead. " + "This allocation guard does not replace runtime or byte limits for other expression operations." ), layer="available", documentation_types=("admin", "user"), diff --git a/src/govoplan_dataflow/backend/preview_limits.py b/src/govoplan_dataflow/backend/preview_limits.py new file mode 100644 index 0000000..c45f0ac --- /dev/null +++ b/src/govoplan_dataflow/backend/preview_limits.py @@ -0,0 +1,3 @@ +"""Shared existing limits for reference-preview results and allocating expressions.""" + +MAX_RESULT_BYTES = 1_000_000 diff --git a/tests/test_padding_budget.py b/tests/test_padding_budget.py new file mode 100644 index 0000000..4be115d --- /dev/null +++ b/tests/test_padding_budget.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import unittest + +from govoplan_dataflow.backend.executor import ( + MAX_RESULT_BYTES, + PipelineExecutionError, + execute_preview, +) +from govoplan_dataflow.backend.expressions import ExpressionError, evaluate_expression +from govoplan_dataflow.backend.manifest import get_manifest +from govoplan_dataflow.backend.schemas import ( + GraphEdge, + GraphNode, + GraphPosition, + PipelineGraph, +) + + +class _UnreadableFill: + def __str__(self) -> str: + raise AssertionError( + "Oversized padding must be rejected before reading or multiplying its fill." + ) + + +def _expression_graph(expression: str, *, result_type: str = "string") -> PipelineGraph: + return PipelineGraph( + nodes=[ + GraphNode( + id="source", + type="source.inline", + label="Source", + position=GraphPosition(x=0, y=0), + config={"source_name": "fixture", "rows": [{"value": "x"}]}, + ), + GraphNode( + id="padding", + type="expression", + label="Padding", + position=GraphPosition(x=200, y=0), + config={ + "target_column": "padded", + "expression": expression, + "result_type": result_type, + }, + ), + GraphNode( + id="output", + type="output", + label="Output", + position=GraphPosition(x=400, y=0), + config={}, + ), + ], + edges=[ + GraphEdge(id="source-padding", source="source", target="padding"), + GraphEdge(id="padding-output", source="padding", target="output"), + ], + ) + + +class PaddingBudgetTests(unittest.TestCase): + def test_oversized_padding_is_rejected_before_fill_evaluation_or_allocation( + self, + ) -> None: + for operation in ("lpad", "rpad"): + for length in (MAX_RESULT_BYTES + 1, 10**100): + with self.subTest(operation=operation, length=length): + with self.assertRaisesRegex( + ExpressionError, "Padding length.*1,000,000" + ): + evaluate_expression( + f"{operation}('x', {length}, fill)", + {"fill": _UnreadableFill()}, + ) + + def test_budget_cannot_be_bypassed_by_hiding_large_padding_in_a_small_scalar( + self, + ) -> None: + for wrapper in ("length({})", "substring({}, 1, 1)"): + with self.subTest(wrapper=wrapper): + expression = wrapper.format(f"lpad('x', {MAX_RESULT_BYTES + 1}, fill)") + with self.assertRaises(ExpressionError): + evaluate_expression(expression, {"fill": _UnreadableFill()}) + + def test_existing_boundary_and_ordinary_padding_are_preserved(self) -> None: + self.assertEqual(1_000_000, MAX_RESULT_BYTES) + for operation in ("lpad", "rpad"): + with self.subTest(operation=operation): + value = evaluate_expression( + f"{operation}('x', {MAX_RESULT_BYTES}, '0')", {} + ) + self.assertEqual(MAX_RESULT_BYTES, len(value)) + self.assertEqual(1, value.count("x")) + self.assertEqual( + "abc", evaluate_expression(f"{operation}('abcdef', 3, '')", {}) + ) + self.assertEqual( + "", evaluate_expression(f"{operation}('abcdef', 0, '')", {}) + ) + self.assertEqual( + "abc", evaluate_expression(f"{operation}('abc', 3, '')", {}) + ) + + def test_null_negative_and_empty_fill_semantics_are_unchanged(self) -> None: + for operation in ("lpad", "rpad"): + with self.subTest(operation=operation): + self.assertIsNone( + evaluate_expression(f"{operation}(NULL, {10**100}, '')", {}) + ) + self.assertIsNone(evaluate_expression(f"{operation}(NULL, -1, '')", {})) + with self.assertRaisesRegex(ValueError, "cannot be negative"): + evaluate_expression(f"{operation}('x', -1, '0')", {}) + with self.assertRaisesRegex(ValueError, "fill text cannot be empty"): + evaluate_expression(f"{operation}('x', 2, '')", {}) + + def test_multibyte_fill_and_truncation_preserve_character_semantics(self) -> None: + self.assertEqual("ö🙂öÄ", evaluate_expression("lpad('Ä', 4, 'ö🙂')", {})) + self.assertEqual("Äö🙂ö", evaluate_expression("rpad('Ä', 4, 'ö🙂')", {})) + self.assertEqual("🙂ä", evaluate_expression("lpad('🙂ä中', 2, '0')", {})) + result = execute_preview( + _expression_graph("rpad(value, 4, 'ö🙂')"), row_limit=10 + ) + self.assertEqual("xö🙂ö", result.rows[0]["padded"]) + + def test_preview_reports_padding_guard_at_owning_node_and_retains_final_byte_limit( + self, + ) -> None: + with self.assertRaisesRegex(PipelineExecutionError, "Padding length") as raised: + execute_preview( + _expression_graph( + f"length(lpad(value, {MAX_RESULT_BYTES + 1}, '0'))", + result_type="integer", + ), + row_limit=10, + ) + self.assertEqual("padding", raised.exception.node_id) + # Non-ASCII characters need several serialized bytes. The preallocation + # character bound supplements, and never replaces, the node byte bound. + with self.assertRaisesRegex( + PipelineExecutionError, "one-megabyte result limit" + ) as raised: + execute_preview(_expression_graph("rpad(value, 200000, 'ö')"), row_limit=10) + self.assertEqual("padding", raised.exception.node_id) + + def test_user_and_operator_documentation_explains_intermediate_padding_limit_in_both_languages( + self, + ) -> None: + topic = next( + topic + for topic in get_manifest().documentation + if topic.id == "dataflow.reference.nodes-and-expressions" + ) + self.assertIn("user", topic.documentation_types) + self.assertIn("admin", topic.documentation_types) + for text in (topic.body, topic.translations["de"]["body"]): + self.assertIn("LPAD", text) + self.assertIn("RPAD", text) + self.assertIn("LENGTH", text) + self.assertIn("SUBSTRING", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 8a1b53f..bd26623 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/dataflow-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "src/index.ts", @@ -18,7 +18,7 @@ "test:structure": "node scripts/test-dataflow-page-structure.mjs" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "@xyflow/react": "^12.11.2", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", diff --git a/webui/src/features/dataflow/DataflowPage.tsx b/webui/src/features/dataflow/DataflowPage.tsx index 9e09065..f612115 100644 --- a/webui/src/features/dataflow/DataflowPage.tsx +++ b/webui/src/features/dataflow/DataflowPage.tsx @@ -615,6 +615,21 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings return ( + void loadPipelines(draft?.id), loading, label: "Refresh pipelines" }} + contextActions={Pipelines} + createAction={} + variant="primary" + onClick={createNew} + disabled={!canWrite} + disabledReason={!canWrite ? DATAFLOW_I18N.writeReason : undefined} + />} + /> - void loadPipelines(draft?.id), loading, label: "Refresh pipelines" }} - contextActions={Pipelines} - createAction={} - variant="primary" - onClick={createNew} - disabled={!canWrite} - disabledReason={!canWrite ? DATAFLOW_I18N.writeReason : undefined} - />} - />