Keep the file name a node gave an artifact

A run_artifact row is keyed by the message the bytes left on, and that was
also the only name it could answer with — so an `@run:` reference resolved
through the row was the same bytes under a name its producer never chose.
The row now records the file name beside the message name; rows written
before the column answer as they always did.

The fallback also checks the bytes are still in the store, which the bare
digest spelling beside it has always done. A missing blob now fails at
submit rather than in the middle of the run that wanted it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:45:11 +02:00
co-authored by Claude Opus 5
parent 001ec7b282
commit 2e82367926
4 changed files with 81 additions and 8 deletions
@@ -0,0 +1,38 @@
"""run_artifact.filename
The row is keyed by the message the bytes left on, and that was also the only
name it could answer with. A reference rebuilt from it was then the same bytes
under a different name from the one the producing node gave them.
Revision ID: e5b8c2f4a913
Revises: a4d9e2b71c68
Create Date: 2026-08-26
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision = "e5b8c2f4a913"
down_revision = "a4d9e2b71c68"
branch_labels = None
depends_on = None
def upgrade():
# Nullable: the rows already here never recorded one, and the message name
# is what they keep answering with.
op.add_column(
"run_artifact",
sa.Column(
"filename",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=True,
),
)
def downgrade():
op.drop_column("run_artifact", "filename")
+18 -6
View File
@@ -184,13 +184,17 @@ def resolve_references(
with Session(db_engine) as session: with Session(db_engine) as session:
for key, text in pending.items(): for key, text in pending.items():
if text.startswith(RUN_REF_PREFIX): if text.startswith(RUN_REF_PREFIX):
resolved[key] = _from_run(session, key, text[len(RUN_REF_PREFIX) :]) resolved[key] = _from_run(
session, key, text[len(RUN_REF_PREFIX) :], artifacts
)
else: else:
resolved[key] = _from_digest(session, key, text, artifacts) resolved[key] = _from_digest(session, key, text, artifacts)
return resolved return resolved
def _from_run(session: Session, key: str, spelling: str) -> Any: def _from_run(
session: Session, key: str, spelling: str, artifacts: ArtifactStore | None = None
) -> Any:
"""``<run id>.<output>`` as the value that run produced.""" """``<run id>.<output>`` as the value that run produced."""
run_id, _, output = spelling.partition(".") run_id, _, output = spelling.partition(".")
if not run_id or not output: if not run_id or not output:
@@ -210,18 +214,25 @@ def _from_run(session: Session, key: str, spelling: str) -> Any:
return copy.deepcopy(result[output]) return copy.deepcopy(result[output])
# The artifact rows are the fallback: bytes a node made that the flow never # The artifact rows are the fallback: bytes a node made that the flow never
# declared as an output. They carry the message name instead, which loads # declared as an output. A row records what the node called the file, so
# the same bytes either way. # the reference rebuilt here is the one its producer made — apart from the
# rows written before there was a column to keep it in, which answer with
# the message name as they always did.
rows = session.exec( rows = session.exec(
select(RunArtifact).where(col(RunArtifact.run_id) == run_id) select(RunArtifact).where(col(RunArtifact.run_id) == run_id)
).all() ).all()
for row in rows: for row in rows:
if output in (row.name, row.name.rsplit(".", 1)[-1]): if output in (row.name, row.name.rsplit(".", 1)[-1]):
if artifacts is not None and artifacts.path(row.digest) is None:
raise RunRejected(
f"Parameter '{key}': run '{run_id}' made '{output}', but its "
"bytes are gone from this installation's store"
)
return { return {
"digest": row.digest, "digest": row.digest,
"size": row.size, "size": row.size,
"media_type": row.media_type or "application/octet-stream", "media_type": row.media_type or "application/octet-stream",
"name": row.name, "name": row.filename or row.name,
} }
known = ", ".join(sorted({*result, *(row.name for row in rows)})) or "none" known = ", ".join(sorted({*result, *(row.name for row in rows)})) or "none"
raise RunRejected( raise RunRejected(
@@ -252,7 +263,7 @@ def _from_digest(
"digest": row.digest, "digest": row.digest,
"size": row.size, "size": row.size,
"media_type": row.media_type or "application/octet-stream", "media_type": row.media_type or "application/octet-stream",
"name": row.name, "name": row.filename or row.name,
} }
@@ -982,6 +993,7 @@ class RunService:
RunArtifact( RunArtifact(
run_id=run_id, run_id=run_id,
name=message[:255], name=message[:255],
filename=str(ref.get("name") or "")[:255] or None,
node=outcome.node[:255], node=outcome.node[:255],
digest=str(ref.get("digest") or "")[:71], digest=str(ref.get("digest") or "")[:71],
size=int(ref.get("size") or 0), size=int(ref.get("size") or 0),
+4
View File
@@ -445,7 +445,11 @@ class RunArtifact(SQLModel, table=True):
__tablename__ = "run_artifact" __tablename__ = "run_artifact"
run_id: str = Field(primary_key=True, max_length=64) run_id: str = Field(primary_key=True, max_length=64)
#: The message the bytes left the node on, which is what addresses them.
name: str = Field(primary_key=True, max_length=255) name: str = Field(primary_key=True, max_length=255)
#: What the node called the file, when it said. Kept because a reference
#: rebuilt from this row is otherwise the same bytes under another name.
filename: str | None = Field(default=None, max_length=255)
node: str = Field(default="", max_length=255) node: str = Field(default="", max_length=255)
digest: str = Field(default="", index=True, max_length=71) digest: str = Field(default="", index=True, max_length=71)
size: int = 0 size: int = 0
+21 -2
View File
@@ -140,6 +140,7 @@ def made_artifact():
RunArtifact( RunArtifact(
run_id=run_id, run_id=run_id,
name="prepare.dataset", name="prepare.dataset",
filename="cities.csv",
node="load", node="load",
digest=digest, digest=digest,
size=12, size=12,
@@ -159,11 +160,29 @@ def test_a_run_reference_resolves_to_what_that_run_produced(made_artifact):
artifact_flow(), {"dataset": f"@run:{run_id}.dataset"} artifact_flow(), {"dataset": f"@run:{run_id}.dataset"}
) )
# The producer's own reference, file name and all — not one rebuilt from # The producer's own reference, whatever type it made it — the row beside
# the row, which carries the message name instead. # it is the fallback, not the first answer.
assert resolved["dataset"] == reference assert resolved["dataset"] == reference
def test_the_artifact_row_answers_with_the_name_the_node_gave_the_file(made_artifact):
"""Bytes the flow never declared as an output are reachable through the row."""
run_id, reference = made_artifact
with Session(db_engine) as session:
run = session.get(Run, run_id)
run.result = {}
session.add(run)
session.commit()
resolved = resolve_references(
artifact_flow(), {"dataset": f"@run:{run_id}.dataset"}
)
assert resolved["dataset"]["digest"] == reference["digest"]
# The file name, not the message it happened to leave on.
assert resolved["dataset"]["name"] == "cities.csv"
def test_a_bare_digest_resolves_to_the_bytes_under_it(made_artifact): def test_a_bare_digest_resolves_to_the_bytes_under_it(made_artifact):
_run_id, reference = made_artifact _run_id, reference = made_artifact
resolved = resolve_references(artifact_flow(), {"dataset": reference["digest"]}) resolved = resolve_references(artifact_flow(), {"dataset": reference["digest"]})