Report a published node that has no code of its own
The store answers the new-node template when nothing was ever written for a
node, so such a node ran — returning {} on every call, reporting active and
ok, and saying nothing anywhere. Unreachable through `fluksio sync`, which
writes every body before it publishes; the editor end was open.
A run of a flow holding one is now refused, and the flow carries a
missing_source issue so it is visible before anybody runs it. A draft is
exempt: a node being written legitimately has no published body yet.
The generated client is regenerated for the new issue code, which also
catches up the drift left by earlier backend work (resources, code_digest,
idempotency_key).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,6 +140,9 @@ class LoadedNode:
|
||||
#: so only an acknowledgement clears it, not a good run and not a rebuild.
|
||||
last_error: str = ""
|
||||
last_error_ts: float | None = None
|
||||
#: Published, and with no body of its own — so what runs is the new-node
|
||||
#: template, which returns nothing at all.
|
||||
missing_source: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -972,6 +975,12 @@ class FlowController:
|
||||
else:
|
||||
owner, local = flow, node_def.id
|
||||
code = self.store.read_node_source(flow, node_def.id, draft=draft)
|
||||
# The store answers the new-node template when nothing was
|
||||
# written, which is what an editor should open with and not
|
||||
# something to run. A draft legitimately has none yet.
|
||||
entry.missing_source = not draft and not self.store.has_node_source(
|
||||
flow, node_def.id
|
||||
)
|
||||
|
||||
# What a node produces before it returns comes back as frames;
|
||||
# this puts them through the node's own ports.
|
||||
@@ -1679,6 +1688,21 @@ def _collect_issues(
|
||||
and entry.node.mode == HttpNode.Mode.TRIGGER
|
||||
and not entry.node.secret
|
||||
]
|
||||
# A node whose body was never stored runs the new-node template, which
|
||||
# returns nothing — quietly, and looking healthy the whole time.
|
||||
issues += [
|
||||
ValidationIssue(
|
||||
code="missing_source",
|
||||
message=(
|
||||
f"Node '{entry.id.rpartition('.')[2]}' has no stored code. It "
|
||||
"runs as an empty node and publishes nothing."
|
||||
),
|
||||
flow=entry.flow,
|
||||
node=entry.id,
|
||||
)
|
||||
for entry in loaded.values()
|
||||
if entry.missing_source
|
||||
]
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ class ValidationIssue(BaseModel):
|
||||
"node_error",
|
||||
"unauthenticated_hook",
|
||||
"self_loop_needs_initial",
|
||||
"missing_source",
|
||||
]
|
||||
message: str
|
||||
flow: str = ""
|
||||
|
||||
@@ -48,12 +48,13 @@ from sqlmodel import Session, col, select
|
||||
|
||||
from fluksio.core.db import engine as db_engine
|
||||
from fluksio.flow.artifacts import ArtifactStore, is_reference, valid_digest
|
||||
from fluksio.flow.controller import FlowController, RunContext
|
||||
from fluksio.flow.controller import NODE_TYPES, FlowController, RunContext
|
||||
from fluksio.flow.messages import qualify
|
||||
from fluksio.flow.pipeline import CacheHit, NodeOutcome, Pipeline
|
||||
from fluksio.flow.queue import WorkItem, WorkQueue
|
||||
from fluksio.flow.schemas import FlowDef
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||
from fluksio.flow.state import MemoryState, StateBackend
|
||||
from fluksio.flow.store import FlowStore
|
||||
from fluksio.models import Run, RunArtifact, RunMetric, RunNode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -106,7 +107,9 @@ def digest_of(params: dict[str, Any], seed: int | None) -> str:
|
||||
return hashlib.sha256(canonical.encode()).hexdigest()
|
||||
|
||||
|
||||
def batch_issues(flow: FlowDef) -> list[str]:
|
||||
def batch_issues(
|
||||
flow: FlowDef, store: FlowStore | None = None, draft: bool = False
|
||||
) -> list[str]:
|
||||
"""Why this flow cannot be run as a batch, if it cannot.
|
||||
|
||||
One thing genuinely breaks: a port with a discretization interval holds
|
||||
@@ -118,6 +121,12 @@ def batch_issues(flow: FlowDef) -> list[str]:
|
||||
|
||||
A delay node is fine; without a queue to defer into it simply sleeps,
|
||||
which in a run is what was asked for.
|
||||
|
||||
The other is a node with no body. The store answers a new node's template
|
||||
when nothing was ever written for one, so such a node runs — and returns
|
||||
``{}`` every time, without a word. `fluksio sync` writes every body before
|
||||
it publishes, so this is unreachable from there; it is the other end that
|
||||
is open.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
for node in flow.nodes:
|
||||
@@ -129,9 +138,28 @@ def batch_issues(flow: FlowDef) -> list[str]:
|
||||
"the value would be dropped. Remove the interval, or mark "
|
||||
"the port as streaming if it is a curve being thinned out."
|
||||
)
|
||||
if store is not None and _has_no_body(store, flow.name, node, draft):
|
||||
issues.append(
|
||||
f"Node '{node.id}' has no stored code. It would run as an "
|
||||
"empty node and publish nothing, so the run is refused."
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _has_no_body(
|
||||
store: FlowStore, flow: str, node: NodeDef, draft: bool = False
|
||||
) -> bool:
|
||||
"""A node that should carry its own source, and does not.
|
||||
|
||||
A shared node runs the library's copy, so it is not this: a missing library
|
||||
fails the node loudly on its own.
|
||||
"""
|
||||
node_type = NODE_TYPES.get(node.type)
|
||||
if node_type is None or not node_type.has_source or node.source_ref:
|
||||
return False
|
||||
return not store.has_node_source(flow, node.id, draft=draft)
|
||||
|
||||
|
||||
def required_labels(flow: FlowDef) -> list[str]:
|
||||
"""Worker labels this flow cannot run without.
|
||||
|
||||
@@ -644,7 +672,7 @@ class RunService:
|
||||
if existing is not None:
|
||||
return existing
|
||||
flow = self.controller.store.read_flow(flow_name, draft=draft)
|
||||
issues = batch_issues(flow)
|
||||
issues = batch_issues(flow, self.controller.store, draft=draft)
|
||||
if issues:
|
||||
raise RunRejected(" ".join(issues))
|
||||
params = params or {}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Editing writes drafts; only publishing changes what the engine reads."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.messages import MessageSpec
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||
from fluksio.flow.store import FlowStore, StaleVersion
|
||||
@@ -106,6 +108,31 @@ def test_an_edited_source_alone_counts_as_a_draft(store: FlowStore):
|
||||
assert store.read_node_source("heating", "sensor") == EDITED
|
||||
|
||||
|
||||
def test_a_published_node_with_no_body_is_reported(store: FlowStore):
|
||||
"""It would run the new-node template, which returns nothing and says so."""
|
||||
store.write_flow(a_flow())
|
||||
controller = FlowController(store)
|
||||
|
||||
asyncio.run(controller.reload())
|
||||
(issue,) = [i for i in controller.issues if i.code == "missing_source"]
|
||||
assert issue.node == "heating.sensor"
|
||||
assert not issue.advisory
|
||||
# It still loads: one node with no body does not take the flow down.
|
||||
assert controller.get_node("heating.sensor") is not None
|
||||
|
||||
store.write_node_source("heating", "sensor", SOURCE)
|
||||
asyncio.run(controller.reload())
|
||||
assert [i for i in controller.issues if i.code == "missing_source"] == []
|
||||
|
||||
|
||||
def test_a_node_being_written_in_the_editor_is_not_reported(store: FlowStore):
|
||||
"""A draft legitimately has no published body yet — that is what a draft is."""
|
||||
store.write_draft(a_flow(), 0)
|
||||
controller = FlowController(store)
|
||||
|
||||
assert controller.preview("heating").issues == []
|
||||
|
||||
|
||||
def test_resaving_the_published_source_creates_no_draft(store: FlowStore):
|
||||
store.write_flow(a_flow())
|
||||
store.write_node_source("heating", "sensor", SOURCE)
|
||||
|
||||
@@ -317,6 +317,25 @@ def test_a_rate_limited_port_cannot_be_run_as_a_batch():
|
||||
assert not batch_issues(double_flow())
|
||||
|
||||
|
||||
def test_a_node_with_no_stored_code_cannot_be_run():
|
||||
"""The store answers a template for one, and a template publishes nothing."""
|
||||
|
||||
class Store:
|
||||
def __init__(self, *, written: bool):
|
||||
self.written = written
|
||||
|
||||
def has_node_source(self, flow, node_id, draft=False):
|
||||
return self.written
|
||||
|
||||
flow = double_flow()
|
||||
(issue,) = batch_issues(flow, Store(written=False))
|
||||
assert "no stored code" in issue
|
||||
assert not batch_issues(flow, Store(written=True))
|
||||
# A shared node runs the library's copy, so it is not this node's to have.
|
||||
flow.nodes[0].source_ref = "shared"
|
||||
assert not batch_issues(flow, Store(written=False))
|
||||
|
||||
|
||||
def test_a_streaming_port_may_thin_itself_out():
|
||||
flow = double_flow()
|
||||
flow.nodes[0].provides = [spec("loss", interval=0.5, stream=True)]
|
||||
|
||||
Reference in New Issue
Block a user