zenml-nightly 0.83.1.dev20250706__py3-none-any.whl → 0.83.1.dev20250708__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,36 @@ from pydantic import field_validator
19
19
 
20
20
  from zenml.config.base_settings import BaseSettings
21
21
  from zenml.integrations.kubernetes import serialization_utils
22
+ from zenml.logger import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ _pod_settings_logged_warnings = []
28
+
29
+
30
+ def warn_if_invalid_model_data(data: Any, class_name: str) -> None:
31
+ """Validates the data of a Kubernetes model.
32
+
33
+ Args:
34
+ data: The data to validate.
35
+ class_name: Name of the class of the model.
36
+ """
37
+ if not isinstance(data, dict):
38
+ return
39
+
40
+ try:
41
+ serialization_utils.deserialize_kubernetes_model(data, class_name)
42
+ except KeyError as e:
43
+ if str(e) not in _pod_settings_logged_warnings:
44
+ _pod_settings_logged_warnings.append(str(e))
45
+ logger.warning(
46
+ "Invalid data for Kubernetes model class `%s`: %s. "
47
+ "Hint: Kubernetes expects attribute names in CamelCase, not "
48
+ "snake_case.",
49
+ class_name,
50
+ e,
51
+ )
22
52
 
23
53
 
24
54
  class KubernetesPodSettings(BaseSettings):
@@ -77,6 +107,7 @@ class KubernetesPodSettings(BaseSettings):
77
107
  serialization_utils.serialize_kubernetes_model(element)
78
108
  )
79
109
  else:
110
+ warn_if_invalid_model_data(element, "V1Volume")
80
111
  result.append(element)
81
112
 
82
113
  return result
@@ -101,6 +132,7 @@ class KubernetesPodSettings(BaseSettings):
101
132
  serialization_utils.serialize_kubernetes_model(element)
102
133
  )
103
134
  else:
135
+ warn_if_invalid_model_data(element, "V1VolumeMount")
104
136
  result.append(element)
105
137
 
106
138
  return result
@@ -121,6 +153,7 @@ class KubernetesPodSettings(BaseSettings):
121
153
  if isinstance(value, V1Affinity):
122
154
  return serialization_utils.serialize_kubernetes_model(value)
123
155
  else:
156
+ warn_if_invalid_model_data(value, "V1Affinity")
124
157
  return value
125
158
 
126
159
  @field_validator("tolerations", mode="before")
@@ -143,6 +176,7 @@ class KubernetesPodSettings(BaseSettings):
143
176
  serialization_utils.serialize_kubernetes_model(element)
144
177
  )
145
178
  else:
179
+ warn_if_invalid_model_data(element, "V1Toleration")
146
180
  result.append(element)
147
181
 
148
182
  return result
@@ -163,6 +197,7 @@ class KubernetesPodSettings(BaseSettings):
163
197
  if isinstance(value, V1ResourceRequirements):
164
198
  return serialization_utils.serialize_kubernetes_model(value)
165
199
  else:
200
+ warn_if_invalid_model_data(value, "V1ResourceRequirements")
166
201
  return value
167
202
 
168
203
  @field_validator("env", mode="before")
@@ -185,6 +220,7 @@ class KubernetesPodSettings(BaseSettings):
185
220
  serialization_utils.serialize_kubernetes_model(element)
186
221
  )
187
222
  else:
223
+ warn_if_invalid_model_data(element, "V1EnvVar")
188
224
  result.append(element)
189
225
 
190
226
  return result
@@ -209,6 +245,7 @@ class KubernetesPodSettings(BaseSettings):
209
245
  serialization_utils.serialize_kubernetes_model(element)
210
246
  )
211
247
  else:
248
+ warn_if_invalid_model_data(element, "V1EnvFromSource")
212
249
  result.append(element)
213
250
 
214
251
  return result
@@ -117,7 +117,8 @@ def deserialize_kubernetes_model(data: Dict[str, Any], class_name: str) -> Any:
117
117
  if key not in attribute_mapping:
118
118
  raise KeyError(
119
119
  f"Got value for attribute {key} which is not one of the "
120
- f"available attributes {set(attribute_mapping)}."
120
+ f"available attributes for class {class_name}: "
121
+ f"{set(attribute_mapping)}."
121
122
  )
122
123
 
123
124
  attribute_name = attribute_mapping[key]
@@ -18,6 +18,7 @@ import os
18
18
  import re
19
19
  import sys
20
20
  import time
21
+ from contextlib import nullcontext
21
22
  from contextvars import ContextVar
22
23
  from types import TracebackType
23
24
  from typing import Any, Callable, List, Optional, Type, Union
@@ -30,7 +31,9 @@ from zenml.artifacts.utils import (
30
31
  _load_file_from_artifact_store,
31
32
  _strip_timestamp_from_multiline_string,
32
33
  )
34
+ from zenml.client import Client
33
35
  from zenml.constants import (
36
+ ENV_ZENML_DISABLE_PIPELINE_LOGS_STORAGE,
34
37
  ENV_ZENML_DISABLE_STEP_NAMES_IN_LOGS,
35
38
  handle_bool_env_var,
36
39
  )
@@ -41,6 +44,11 @@ from zenml.logging import (
41
44
  STEP_LOGS_STORAGE_MAX_MESSAGES,
42
45
  STEP_LOGS_STORAGE_MERGE_INTERVAL_SECONDS,
43
46
  )
47
+ from zenml.models import (
48
+ LogsRequest,
49
+ PipelineDeploymentResponse,
50
+ PipelineRunUpdate,
51
+ )
44
52
  from zenml.utils.time_utils import utc_now
45
53
  from zenml.zen_stores.base_zen_store import BaseZenStore
46
54
 
@@ -584,3 +592,76 @@ class PipelineLogsStorageContext:
584
592
  return output
585
593
 
586
594
  return wrapped_flush
595
+
596
+
597
+ def setup_orchestrator_logging(
598
+ run_id: str, deployment: "PipelineDeploymentResponse"
599
+ ) -> Any:
600
+ """Set up logging for an orchestrator environment.
601
+
602
+ This function can be reused by different orchestrators to set up
603
+ consistent logging behavior.
604
+
605
+ Args:
606
+ run_id: The pipeline run ID.
607
+ deployment: The deployment of the pipeline run.
608
+
609
+ Returns:
610
+ The logs context (PipelineLogsStorageContext)
611
+ """
612
+ try:
613
+ step_logging_enabled = True
614
+
615
+ # Check whether logging is enabled
616
+ if handle_bool_env_var(ENV_ZENML_DISABLE_PIPELINE_LOGS_STORAGE, False):
617
+ step_logging_enabled = False
618
+ else:
619
+ if (
620
+ deployment.pipeline_configuration.enable_pipeline_logs
621
+ is not None
622
+ ):
623
+ step_logging_enabled = (
624
+ deployment.pipeline_configuration.enable_pipeline_logs
625
+ )
626
+
627
+ if not step_logging_enabled:
628
+ return nullcontext()
629
+
630
+ # Fetch the active stack
631
+ client = Client()
632
+ active_stack = client.active_stack
633
+
634
+ # Configure the logs
635
+ logs_uri = prepare_logs_uri(
636
+ artifact_store=active_stack.artifact_store,
637
+ )
638
+
639
+ logs_context = PipelineLogsStorageContext(
640
+ logs_uri=logs_uri,
641
+ artifact_store=active_stack.artifact_store,
642
+ prepend_step_name=False,
643
+ )
644
+
645
+ logs_model = LogsRequest(
646
+ uri=logs_uri,
647
+ source="orchestrator",
648
+ artifact_store_id=active_stack.artifact_store.id,
649
+ )
650
+
651
+ # Add orchestrator logs to the pipeline run
652
+ try:
653
+ run_update = PipelineRunUpdate(add_logs=[logs_model])
654
+ client.zen_store.update_run(
655
+ run_id=UUID(run_id), run_update=run_update
656
+ )
657
+ except Exception as e:
658
+ logger.error(
659
+ f"Failed to add orchestrator logs to the run {run_id}: {e}"
660
+ )
661
+ raise e
662
+ return logs_context
663
+ except Exception as e:
664
+ logger.error(
665
+ f"Failed to setup orchestrator logging for run {run_id}: {e}"
666
+ )
667
+ return nullcontext()
@@ -34,7 +34,7 @@ class LogsRequest(BaseRequest):
34
34
  """Request model for logs."""
35
35
 
36
36
  uri: str = Field(title="The uri of the logs file")
37
-
37
+ source: str = Field(title="The source of the logs file")
38
38
  artifact_store_id: UUID = Field(
39
39
  title="The artifact store ID to associate the logs with.",
40
40
  )
@@ -75,6 +75,10 @@ class LogsResponseBody(BaseDatedResponseBody):
75
75
  title="The uri of the logs file",
76
76
  max_length=TEXT_FIELD_MAX_LENGTH,
77
77
  )
78
+ source: str = Field(
79
+ title="The source of the logs file",
80
+ max_length=TEXT_FIELD_MAX_LENGTH,
81
+ )
78
82
 
79
83
 
80
84
  class LogsResponseMetadata(BaseResponseMetadata):
@@ -126,6 +130,15 @@ class LogsResponse(
126
130
  """
127
131
  return self.get_body().uri
128
132
 
133
+ @property
134
+ def source(self) -> str:
135
+ """The `source` property.
136
+
137
+ Returns:
138
+ the value of the property.
139
+ """
140
+ return self.get_body().source
141
+
129
142
  @property
130
143
  def step_run_id(self) -> Optional[UUID]:
131
144
  """The `step_run_id` property.
@@ -153,6 +153,9 @@ class PipelineRunUpdate(BaseUpdate):
153
153
  remove_tags: Optional[List[str]] = Field(
154
154
  default=None, title="Tags to remove from the pipeline run."
155
155
  )
156
+ add_logs: Optional[List[LogsRequest]] = Field(
157
+ default=None, title="New logs to add to the pipeline run."
158
+ )
156
159
 
157
160
  model_config = ConfigDict(protected_namespaces=())
158
161
 
@@ -265,6 +268,10 @@ class PipelineRunResponseResources(ProjectScopedResponseResources):
265
268
  title="Logs associated with this pipeline run.",
266
269
  default=None,
267
270
  )
271
+ log_collection: Optional[List["LogsResponse"]] = Field(
272
+ title="Logs associated with this pipeline run.",
273
+ default=None,
274
+ )
268
275
 
269
276
  # TODO: In Pydantic v2, the `model_` is a protected namespaces for all
270
277
  # fields defined under base models. If not handled, this raises a warning.
@@ -601,6 +608,15 @@ class PipelineRunResponse(
601
608
  """
602
609
  return self.get_resources().logs
603
610
 
611
+ @property
612
+ def log_collection(self) -> Optional[List["LogsResponse"]]:
613
+ """The `log_collection` property.
614
+
615
+ Returns:
616
+ the value of the property.
617
+ """
618
+ return self.get_resources().log_collection
619
+
604
620
 
605
621
  # ------------------ Filter Model ------------------
606
622
 
@@ -241,6 +241,7 @@ class StepLauncher:
241
241
 
242
242
  logs_model = LogsRequest(
243
243
  uri=logs_uri,
244
+ source="execution",
244
245
  artifact_store_id=self._stack.artifact_store.id,
245
246
  )
246
247
 
@@ -856,6 +856,7 @@ To avoid this consider setting pipeline parameters only in one place (config or
856
856
 
857
857
  logs_model = LogsRequest(
858
858
  uri=logs_uri,
859
+ source="client",
859
860
  artifact_store_id=stack.artifact_store.id,
860
861
  )
861
862
 
@@ -436,22 +436,24 @@ def stop_run(
436
436
  @async_fastapi_endpoint_wrapper
437
437
  def run_logs(
438
438
  run_id: UUID,
439
+ source: str,
439
440
  offset: int = 0,
440
441
  length: int = 1024 * 1024 * 16, # Default to 16MiB of data
441
442
  _: AuthContext = Security(authorize),
442
443
  ) -> str:
443
- """Get pipeline run logs.
444
+ """Get pipeline run logs for a specific source.
444
445
 
445
446
  Args:
446
447
  run_id: ID of the pipeline run.
448
+ source: Required source to get logs for.
447
449
  offset: The offset from which to start reading.
448
450
  length: The amount of bytes that should be read.
449
451
 
450
452
  Returns:
451
- The pipeline run logs.
453
+ Logs for the specified source.
452
454
 
453
455
  Raises:
454
- KeyError: If no logs are available for the pipeline run.
456
+ KeyError: If no logs are found for the specified source.
455
457
  """
456
458
  store = zen_store()
457
459
 
@@ -461,19 +463,26 @@ def run_logs(
461
463
  hydrate=True,
462
464
  )
463
465
 
464
- if run.deployment_id:
466
+ # Handle runner logs from workload manager
467
+ if run.deployment_id and source == "runner":
465
468
  deployment = store.get_deployment(run.deployment_id)
466
469
  if deployment.template_id and server_config().workload_manager_enabled:
467
- return workload_manager().get_logs(workload_id=deployment.id)
468
-
469
- logs = run.logs
470
- if logs is None:
471
- raise KeyError("No logs available for this pipeline run")
472
-
473
- return fetch_logs(
474
- zen_store=store,
475
- artifact_store_id=logs.artifact_store_id,
476
- logs_uri=logs.uri,
477
- offset=offset,
478
- length=length,
479
- )
470
+ workload_logs = workload_manager().get_logs(
471
+ workload_id=deployment.id
472
+ )
473
+ return workload_logs
474
+
475
+ # Handle logs from log collection
476
+ if run.log_collection:
477
+ for log_entry in run.log_collection:
478
+ if log_entry.source == source:
479
+ return fetch_logs(
480
+ zen_store=store,
481
+ artifact_store_id=log_entry.artifact_store_id,
482
+ logs_uri=log_entry.uri,
483
+ offset=offset,
484
+ length=length,
485
+ )
486
+
487
+ # If no logs found for the specified source, raise an error
488
+ raise KeyError(f"No logs found for source '{source}' in run {run_id}")
@@ -0,0 +1,68 @@
1
+ """adding-source-to-logs [85289fea86ff].
2
+
3
+ Revision ID: 85289fea86ff
4
+ Revises: 5bb25e95849c
5
+ Create Date: 2025-06-30 18:18:24.539265
6
+
7
+ """
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = "85289fea86ff"
14
+ down_revision = "5bb25e95849c"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ """Upgrade database schema and/or data, creating a new revision."""
21
+ # Add the source column as nullable first
22
+ with op.batch_alter_table("logs", schema=None) as batch_op:
23
+ batch_op.add_column(
24
+ sa.Column("source", sa.VARCHAR(255), nullable=True)
25
+ )
26
+
27
+ # Populate the source field based on existing data
28
+ connection = op.get_bind()
29
+
30
+ # Set source to "step" where step_run_id is present
31
+ connection.execute(
32
+ sa.text("""
33
+ UPDATE logs
34
+ SET source = 'step'
35
+ WHERE step_run_id IS NOT NULL
36
+ """)
37
+ )
38
+
39
+ # Set source to "client" for all other cases (where step_run_id is null)
40
+ connection.execute(
41
+ sa.text("""
42
+ UPDATE logs
43
+ SET source = 'client'
44
+ WHERE step_run_id IS NULL
45
+ """)
46
+ )
47
+
48
+ # Make the source column not nullable
49
+ with op.batch_alter_table("logs", schema=None) as batch_op:
50
+ batch_op.alter_column(
51
+ "source",
52
+ existing_type=sa.VARCHAR(255),
53
+ nullable=False,
54
+ )
55
+ # Add unique constraint: source is unique for each combination of pipeline_run_id and step_run_id
56
+ batch_op.create_unique_constraint(
57
+ "unique_source_per_run_and_step",
58
+ ["source", "pipeline_run_id", "step_run_id"],
59
+ )
60
+
61
+
62
+ def downgrade() -> None:
63
+ """Downgrade database schema and/or data back to the previous revision."""
64
+ with op.batch_alter_table("logs", schema=None) as batch_op:
65
+ batch_op.drop_constraint(
66
+ "unique_source_per_run_and_step", type_="unique"
67
+ )
68
+ batch_op.drop_column("source")
@@ -16,7 +16,7 @@
16
16
  from typing import Any, Optional
17
17
  from uuid import UUID
18
18
 
19
- from sqlalchemy import TEXT, Column
19
+ from sqlalchemy import TEXT, VARCHAR, Column, UniqueConstraint
20
20
  from sqlmodel import Field, Relationship
21
21
 
22
22
  from zenml.models import (
@@ -35,9 +35,18 @@ class LogsSchema(BaseSchema, table=True):
35
35
  """SQL Model for logs."""
36
36
 
37
37
  __tablename__ = "logs"
38
+ __table_args__ = (
39
+ UniqueConstraint(
40
+ "source",
41
+ "pipeline_run_id",
42
+ "step_run_id",
43
+ name="unique_source_per_run_and_step",
44
+ ),
45
+ )
38
46
 
39
47
  # Fields
40
48
  uri: str = Field(sa_column=Column(TEXT, nullable=False))
49
+ source: str = Field(sa_column=Column(VARCHAR(255), nullable=False))
41
50
 
42
51
  # Foreign Keys
43
52
  pipeline_run_id: Optional[UUID] = build_foreign_key_field(
@@ -87,12 +96,12 @@ class LogsSchema(BaseSchema, table=True):
87
96
  include_resources: Whether the resources will be filled.
88
97
  **kwargs: Keyword arguments to allow schema specific logic
89
98
 
90
-
91
99
  Returns:
92
100
  The created `LogsResponse`.
93
101
  """
94
102
  body = LogsResponseBody(
95
103
  uri=self.uri,
104
+ source=self.source,
96
105
  created=self.created,
97
106
  updated=self.updated,
98
107
  )
@@ -158,9 +158,9 @@ class PipelineRunSchema(NamedSchema, RunMetadataInterface, table=True):
158
158
  overlaps="run_metadata",
159
159
  ),
160
160
  )
161
- logs: Optional["LogsSchema"] = Relationship(
161
+ logs: List["LogsSchema"] = Relationship(
162
162
  back_populates="pipeline_run",
163
- sa_relationship_kwargs={"cascade": "delete", "uselist": False},
163
+ sa_relationship_kwargs={"cascade": "delete"},
164
164
  )
165
165
  step_runs: List["StepRunSchema"] = Relationship(
166
166
  sa_relationship_kwargs={"cascade": "delete"},
@@ -531,13 +531,22 @@ class PipelineRunSchema(NamedSchema, RunMetadataInterface, table=True):
531
531
 
532
532
  resources = None
533
533
  if include_resources:
534
+ # Add the client logs as "logs" if they exist, for backwards compatibility
535
+ # TODO: This will be safe to remove in future releases (>0.84.0).
536
+ client_logs = [
537
+ log_entry
538
+ for log_entry in self.logs
539
+ if log_entry.source == "client"
540
+ ]
541
+
534
542
  resources = PipelineRunResponseResources(
535
543
  user=self.user.to_model() if self.user else None,
536
544
  model_version=self.model_version.to_model()
537
545
  if self.model_version
538
546
  else None,
539
547
  tags=[tag.to_model() for tag in self.tags],
540
- logs=self.logs.to_model() if self.logs else None,
548
+ logs=client_logs[0].to_model() if client_logs else None,
549
+ log_collection=[log.to_model() for log in self.logs],
541
550
  )
542
551
 
543
552
  return PipelineRunResponse(
@@ -5677,7 +5677,9 @@ class SqlZenStore(BaseZenStore):
5677
5677
  The created pipeline run.
5678
5678
 
5679
5679
  Raises:
5680
- EntityExistsError: If a run with the same name already exists.
5680
+ EntityExistsError: If a run with the same name already exists or
5681
+ a log entry with the same source already exists within the
5682
+ scope of the same pipeline run.
5681
5683
  """
5682
5684
  self._set_request_user_id(request_model=pipeline_run, session=session)
5683
5685
  self._get_reference_schema_by_id(
@@ -5698,23 +5700,6 @@ class SqlZenStore(BaseZenStore):
5698
5700
 
5699
5701
  session.add(new_run)
5700
5702
 
5701
- # Add logs entry for the run if exists
5702
- if pipeline_run.logs is not None:
5703
- self._get_reference_schema_by_id(
5704
- resource=pipeline_run,
5705
- reference_schema=StackComponentSchema,
5706
- reference_id=pipeline_run.logs.artifact_store_id,
5707
- session=session,
5708
- reference_type="logs artifact store",
5709
- )
5710
-
5711
- log_entry = LogsSchema(
5712
- uri=pipeline_run.logs.uri,
5713
- pipeline_run_id=new_run.id,
5714
- artifact_store_id=pipeline_run.logs.artifact_store_id,
5715
- )
5716
- session.add(log_entry)
5717
-
5718
5703
  try:
5719
5704
  session.commit()
5720
5705
  except IntegrityError:
@@ -5736,6 +5721,33 @@ class SqlZenStore(BaseZenStore):
5736
5721
  "already exists."
5737
5722
  )
5738
5723
 
5724
+ # Add logs entry for the run if exists
5725
+ if pipeline_run.logs is not None:
5726
+ self._get_reference_schema_by_id(
5727
+ resource=pipeline_run,
5728
+ reference_schema=StackComponentSchema,
5729
+ reference_id=pipeline_run.logs.artifact_store_id,
5730
+ session=session,
5731
+ reference_type="logs artifact store",
5732
+ )
5733
+
5734
+ log_entry = LogsSchema(
5735
+ uri=pipeline_run.logs.uri,
5736
+ source=pipeline_run.logs.source,
5737
+ pipeline_run_id=new_run.id,
5738
+ artifact_store_id=pipeline_run.logs.artifact_store_id,
5739
+ )
5740
+ try:
5741
+ session.add(log_entry)
5742
+ session.commit()
5743
+ except IntegrityError:
5744
+ session.rollback()
5745
+ raise EntityExistsError(
5746
+ "Unable to create log entry: A log entry with this "
5747
+ f"source '{pipeline_run.logs.source}' already exists "
5748
+ f"within the scope of the same pipeline run '{new_run.id}'."
5749
+ )
5750
+
5739
5751
  if model_version_id := self._get_or_create_model_version_for_run(
5740
5752
  new_run
5741
5753
  ):
@@ -6095,6 +6107,10 @@ class SqlZenStore(BaseZenStore):
6095
6107
 
6096
6108
  Returns:
6097
6109
  The updated pipeline run.
6110
+
6111
+ Raises:
6112
+ EntityExistsError: If a log entry with the same source already
6113
+ exists within the scope of the same pipeline run.
6098
6114
  """
6099
6115
  with Session(self.engine) as session:
6100
6116
  # Check if pipeline run with the given ID exists
@@ -6109,6 +6125,39 @@ class SqlZenStore(BaseZenStore):
6109
6125
  session.commit()
6110
6126
  session.refresh(existing_run)
6111
6127
 
6128
+ # Add logs if specified
6129
+ if run_update.add_logs:
6130
+ try:
6131
+ for log_request in run_update.add_logs:
6132
+ # Validate the artifact store exists
6133
+ self._get_reference_schema_by_id(
6134
+ resource=log_request,
6135
+ reference_schema=StackComponentSchema,
6136
+ reference_id=log_request.artifact_store_id,
6137
+ session=session,
6138
+ reference_type="logs artifact store",
6139
+ )
6140
+
6141
+ # Create the log entry
6142
+ log_entry = LogsSchema(
6143
+ uri=log_request.uri,
6144
+ source=log_request.source,
6145
+ pipeline_run_id=existing_run.id,
6146
+ artifact_store_id=log_request.artifact_store_id,
6147
+ )
6148
+ session.add(log_entry)
6149
+
6150
+ session.commit()
6151
+ except IntegrityError:
6152
+ session.rollback()
6153
+ raise EntityExistsError(
6154
+ "Unable to create log entry: One of the provided sources "
6155
+ f"({', '.join(log.source for log in run_update.add_logs)}) "
6156
+ "already exists within the scope of the same pipeline run "
6157
+ f"'{existing_run.id}'. Existing entry sources: "
6158
+ f"{', '.join(log.source for log in existing_run.logs)}"
6159
+ )
6160
+
6112
6161
  self._attach_tags_to_resources(
6113
6162
  tags=run_update.add_tags,
6114
6163
  resources=existing_run,
@@ -8830,7 +8879,9 @@ class SqlZenStore(BaseZenStore):
8830
8879
  The created step run.
8831
8880
 
8832
8881
  Raises:
8833
- EntityExistsError: if the step run already exists.
8882
+ EntityExistsError: if the step run already exists or a log entry
8883
+ with the same source already exists within the scope of the
8884
+ same step.
8834
8885
  IllegalOperationError: if the pipeline run is stopped or stopping.
8835
8886
  """
8836
8887
  with Session(self.engine) as session:
@@ -8889,11 +8940,20 @@ class SqlZenStore(BaseZenStore):
8889
8940
 
8890
8941
  log_entry = LogsSchema(
8891
8942
  uri=step_run.logs.uri,
8943
+ source=step_run.logs.source,
8892
8944
  step_run_id=step_schema.id,
8893
8945
  artifact_store_id=step_run.logs.artifact_store_id,
8894
8946
  )
8895
- session.add(log_entry)
8896
-
8947
+ try:
8948
+ session.add(log_entry)
8949
+ session.commit()
8950
+ except IntegrityError:
8951
+ session.rollback()
8952
+ raise EntityExistsError(
8953
+ "Unable to create log entry: A log entry with this "
8954
+ f"source '{step_run.logs.source}' already exists "
8955
+ f"within the scope of the same step '{step_schema.id}'."
8956
+ )
8897
8957
  # If cached, attach metadata of the original step
8898
8958
  if (
8899
8959
  step_run.status == ExecutionStatus.CACHED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: zenml-nightly
3
- Version: 0.83.1.dev20250706
3
+ Version: 0.83.1.dev20250708
4
4
  Summary: ZenML: Write production-ready ML code.
5
5
  License: Apache-2.0
6
6
  Keywords: machine learning,production,pipeline,mlops,devops