Commit ae854dc
Add event timestamps to automation conditions for precise trigger/reset resolution (#21494)
When both trigger and reset conditions fire on the same evaluation tick, timestamp comparison now determines which event happened more recently (per-partition). This eliminates the special-case hack for CronTickPassed + NewlyUpdated and enables correct behavior for all condition combinations.
## Key Changes
- Add `TimingMetadata` class to map event timestamps to entity subsets, stored in-memory on `AutomationResult`
- Create `TimedSubsetAutomationCondition` base class for conditions that support per-partition timestamps
- Enrich `CronTickPassedCondition` and `NewlyUpdatedCondition` to return timing metadata (cron tick time and materialization timestamps respectively)
- Add `compute_subsets_by_latest_materialization_timestamp()` to asset graph view for efficient per-partition timestamp lookup with configurable threshold fallback
- Replace SinceCondition's hardcoded `CronTickPassed + NewlyUpdated` special case with generic timestamp-based resolution via `subset_with_later_timestamps_than()`
<details>
<summary>Files Changed</summary>
### Added (2 files)
- `dagster/_core/asset_graph_view/timing_metadata.py` - TimingMetadata class for timestamp → EntitySubset mapping
- `dagster_tests/declarative_automation_tests/automation_condition_tests/builtins/test_event_timestamps.py` - Comprehensive timestamp resolution tests
### Modified (5 files)
- `dagster/_core/definitions/declarative_automation/automation_condition.py` - Add timing_metadata field to AutomationResult
- `dagster/_core/definitions/declarative_automation/operands/subset_automation_condition.py` - New TimedSubsetAutomationCondition base class
- `dagster/_core/definitions/declarative_automation/operands/operands.py` - CronTickPassed and NewlyUpdatedCondition now return TimingMetadata
- `dagster/_core/asset_graph_view/asset_graph_view.py` - Add compute_subsets_by_latest_materialization_timestamp() with threshold-based fallback
- `dagster_tests/declarative_automation_tests/automation_condition_tests/fundamentals/test_result_value_hash.py` - Adjust timing in tests
</details>
## Critical Notes
- Timestamps are in-memory only (not serialized); consumed same-tick by parent SinceCondition
- Per-partition timestamp fetching respects configurable threshold (env var `DAGSTER_MAX_PARTITIONS_FOR_DA_TIMESTAMP_FETCH`, default 100); exceeding it falls back to tick timestamp
- Fully backward compatible: conditions without timing metadata fall back to current behavior (reset wins when both fire simultaneously)
<details>
<summary>original-plan</summary>
# Plan: Add Event Timestamps to Automation Condition Results
## Context
The `SinceCondition` resolves trigger/reset at tick granularity (30s–few minutes). When both trigger and reset fire on the same tick, ordering is ambiguous — there's even a special-case hack (lines 136-170 in `since_operator.py`) for the `NewlyUpdated` + `CronTickPassed` combination. With real event timestamps, SinceCondition could make **per-partition, timestamp-precise** decisions about whether the trigger or reset happened more recently, eliminating the hack and enabling correct behavior for all condition combinations.
### Current problem illustrated
1. Cron tick fires at 12:00:00
2. Asset partition materializes at 12:00:05
3. DA tick runs at 12:00:30 — sees both CronTickPassed and NewlyUpdated as true
4. Without real timestamps: ambiguous ordering, resolved by a hardcoded special case
5. With real timestamps: CronTickPassed has event_timestamp=12:00:00, NewlyUpdated has event_timestamp=12:00:05 → trigger happened after reset → partition stays in true_subset
## Approach: `subsets_with_metadata` + `SubsetResult` return type
Use the existing `subsets_with_metadata` field (already on `AutomationResult`, `AutomationConditionNodeCursor`, `AutomationConditionEvaluation`, and wired through GraphQL) to carry event timestamps. Conditions opt in by returning a `SubsetResult` from `compute_subset()`.
Always-on for conditions that support it. Exact per-partition timestamps (not bucketed).
---
## Implementation Steps
### Step 1: Define `SubsetResult` in `SubsetAutomationCondition`
**File**: `dagster-oss/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/subset_automation_condition.py`
Add a result type that wraps a subset with optional metadata:
```python
@record
class SubsetResult:
subset: EntitySubset
subsets_with_metadata: Sequence[AssetSubsetWithMetadata] = ()
```
Modify `SubsetAutomationCondition.evaluate()` to detect `SubsetResult` vs plain `EntitySubset` from `compute_subset()`, and pass `subsets_with_metadata` through to `AutomationResult`:
```python
async def evaluate(self, context):
if context.candidate_subset.is_empty:
true_subset = context.get_empty_subset()
subsets_with_metadata = []
elif inspect.iscoroutinefunction(self.compute_subset):
result = await self.compute_subset(context)
else:
result = self.compute_subset(context)
if isinstance(result, SubsetResult):
true_subset = result.subset
subsets_with_metadata = result.subsets_with_metadata
else:
true_subset = result
subsets_with_metadata = []
return AutomationResult(context, true_subset, subsets_with_metadata=subsets_with_metadata)
```
Note: `SubsetAutomationCondition.requires_cursor` is currently `False`. Since `subsets_with_metadata` is stored via `node_cursor`, and `node_cursor` returns `None` when `requires_cursor` is `False`, we need to either:
- Override `requires_cursor` to return `True` when metadata is present, or
- Store the metadata on `AutomationConditionEvaluation` only (not on cursor) since it's for same-tick consumption by the parent SinceCondition
**Decision needed**: Since the timestamps are consumed by the parent SinceCondition on the *same* tick (not across ticks), we may not need cursor persistence. The `subsets_with_metadata` on the child's `AutomationResult` is directly accessible to SinceCondition via `trigger_result._subsets_with_metadata`. This avoids needing to enable cursoring on leaf conditions.
### Step 2: Enrich `CronTickPassedCondition`
**File**: `dagster-oss/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py`
When the cron tick has passed, return a `SubsetResult` with the exact cron tick timestamp:
```python
def compute_subset(self, context):
previous_cron_tick = self._get_previous_cron_tick(context.evaluation_time)
if context.previous_evaluation_time is None or previous_cron_tick < context.previous_evaluation_time:
return context.get_empty_subset()
else:
return SubsetResult(
subset=context.candidate_subset,
subsets_with_metadata=[AssetSubsetWithMetadata(
subset=context.candidate_subset.convert_to_serializable_subset(),
metadata={"event_timestamp": FloatMetadataValue(previous_cron_tick.timestamp())},
)],
)
```
Single entry — the cron tick time is uniform across all partitions.
### Step 3: Enrich `NewlyUpdatedCondition` with per-partition timestamps
This requires extending the DB query layer.
**File**: `dagster-oss/python_modules/dagster/dagster/_utils/caching_instance_queryer.py`
The existing `get_asset_partitions_updated_after_cursor()` calls `_get_latest_materialization_or_observation_storage_ids_by_asset_partition()` which returns `Mapping[AssetKeyPartitionKey, int]` (partition → storage_id). We need to also get timestamps.
Option A: Add a companion method `get_asset_partition_timestamps_updated_after_cursor()` that returns `Mapping[AssetKeyPartitionKey, float]`.
Option B: Modify the return type to include both storage_id and timestamp. The underlying `instance.get_latest_storage_id_by_partition()` would need to also return timestamps.
**Recommended**: Option A — less invasive. After getting the updated partition keys (which we already do), make a follow-up query to get event timestamps for those specific storage IDs. Since the set of newly updated partitions per tick is small, this is cheap.
**File**: `dagster-oss/python_modules/dagster/dagster/_core/asset_graph_view/asset_graph_view.py`
Add a new method `compute_updated_since_temporal_context_with_timestamps()` that returns both the subset and a mapping of partition keys to timestamps. The `NewlyUpdatedCondition` calls this instead.
**File**: `dagster-oss/python_modules/dagster/dagster/_core/definitions/declarative_automation/operands/operands.py`
Modify `NewlyUpdatedCondition.compute_subset()`:
```python
async def compute_subset(self, context):
if context.previous_temporal_context is None:
return context.get_empty_subset()
subset, partition_timestamps = await context.asset_graph_view.compute_updated_since_temporal_context_with_timestamps(
key=context.key, temporal_context=context.previous_temporal_context
)
# Group partitions by timestamp
subsets_with_metadata = _group_by_timestamp(subset, partition_timestamps)
return SubsetResult(subset=subset, subsets_with_metadata=subsets_with_metadata)
```
Helper `_group_by_timestamp` groups partition keys sharing the same timestamp into compressed subsets with metadata.
### Step 4: Use timestamps in `SinceCondition` for precise resolution
**File**: `dagster-oss/python_modules/dagster/dagster/_core/definitions/declarative_automation/operators/since_operator.py`
This is the core change. Replace the special-case ordering hack with timestamp-based per-partition resolution:
```python
async def evaluate(self, context):
# ... evaluate trigger and reset as before ...
true_subset = context.previous_true_subset or context.get_empty_subset()
# Extract per-partition timestamps from children
trigger_timestamps = _extract_partition_timestamps(trigger_result)
reset_timestamps = _extract_partition_timestamps(reset_result)
# For partitions in trigger but not reset (or vice versa), simple union/difference
trigger_only = trigger_result.true_subset.compute_difference(reset_result.true_subset)
reset_only = reset_result.true_subset.compute_difference(trigger_result.true_subset)
both = trigger_result.true_subset.compute_intersection(reset_result.true_subset)
true_subset = true_subset.compute_union(trigger_only)
true_subset = true_subset.compute_difference(reset_only)
# For partitions in BOTH trigger and reset on same tick:
# use timestamps to determine which happened more recently
if not both.is_empty:
if trigger_timestamps and reset_timestamps:
# Per-partition comparison: keep if trigger timestamp > reset timestamp
trigger_wins = _partitions_where_trigger_after_reset(
both, trigger_timestamps, reset_timestamps
)
true_subset = true_subset.compute_union(trigger_wins)
# (partitions where reset wins are already excluded by not being unioned)
else:
# Fallback: no timestamps available, use current ordering behavior
true_subset = true_subset.compute_union(both) # or difference, depending on default
# ... update SinceConditionData, return result ...
```
The `_extract_partition_timestamps` helper reads `result._subsets_with_metadata` to build a `Mapping[str, float]` from partition key to `event_timestamp`.
**Key benefit**: The special-case `reset_newly_true` hack for `CronTickPassed + NewlyUpdated` can be **removed** — timestamp comparison handles this generically for all condition combinations.
### Step 5: Propagate timestamps through SinceCondition result
SinceCondition should also emit `subsets_with_metadata` on its own result, carrying the trigger timestamps for partitions in its `true_subset`. This allows parent conditions or the UI to see when each partition was triggered.
---
## Critical Files
| File | Change |
|------|--------|
| `.../operands/subset_automation_condition.py` | Add `SubsetResult`, modify `evaluate()` |
| `.../operands/operands.py` | Modify `CronTickPassedCondition` and `NewlyUpdatedCondition` |
| `.../operators/since_operator.py` | Timestamp-based resolution, remove ordering hack |
| `.../caching_instance_queryer.py` | Add timestamp fetching for updated partitions |
| `.../asset_graph_view/asset_graph_view.py` | Add `compute_updated_since_..._with_timestamps()` |
| `.../serialized_objects.py` | No changes needed — `subsets_with_metadata` already exists |
## Backward Compatibility
- `subsets_with_metadata` was previously empty for modern conditions — populating it is additive
- SinceCondition falls back to current behavior when timestamps aren't available
- No cursor schema changes needed (timestamps are consumed same-tick, not persisted across ticks for this use case)
- Existing serialized cursors deserialize fine
## Verification
1. Existing tests pass: `test_since_condition.py`, `test_updated_since_cron_condition.py`
2. New tests:
- CronTickPassed returns `SubsetResult` with correct cron tick timestamp
- NewlyUpdated returns `SubsetResult` with per-partition materialization timestamps
- SinceCondition with both trigger+reset on same tick resolves correctly using timestamps
- SinceCondition without timestamps (legacy conditions) falls back to current behavior
- The CronTickPassed+NewlyUpdated special case works correctly without the hardcoded hack
3. Integration: verify via the automation condition evaluation UI that timestamps appear in metadata
</details>
<!-- WARNING: Machine-generated. Manual edits may break erk tooling. -->
<!-- erk:metadata-block:plan-header -->
<details>
<summary>plan-header</summary>
```yaml
schema_version: '2'
created_at: '2026-03-09T11:09:55.909154+00:00'
created_by: OwenKephart
plan_comment_id: null
last_dispatched_run_id: null
last_dispatched_node_id: null
last_dispatched_at: null
last_local_impl_at: '2026-03-09T18:35:34.455616+00:00'
last_local_impl_event: ended
last_local_impl_session: 58b2ad56-024a-4c2e-968c-96499cb08551
last_local_impl_user: owen
last_remote_impl_at: null
last_remote_impl_run_id: null
last_remote_impl_session_id: null
branch_name: plnd/add-event-timestamps-since-03-09-1109
created_from_session: 97ef5a88-1256-4b7b-bdf7-1499b38cd062
lifecycle_stage: impl
last_session_branch: async-learn/21494
last_session_id: 58b2ad56-024a-4c2e-968c-96499cb08551
last_session_at: '2026-03-09T11:35:41.024671+00:00'
last_session_source: local
worktree_name: internal-d
```
</details>
<!-- /erk:metadata-block:plan-header -->
---
To replicate this PR locally, run:
```
erk pr teleport 21494
```
Internal-RevId: 94ca2d5557265db32db21c23a4721acc3858f8d41 parent 05f192f commit ae854dc
8 files changed
Lines changed: 411 additions & 33 deletions
File tree
- python_modules/dagster
- dagster_tests/declarative_automation_tests/automation_condition_tests
- builtins
- fundamentals
- dagster/_core
- asset_graph_view
- definitions/declarative_automation
- operands
- operators
Lines changed: 50 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
923 | 923 | | |
924 | 924 | | |
925 | 925 | | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
| 974 | + | |
| 975 | + | |
926 | 976 | | |
927 | 977 | | |
928 | 978 | | |
| |||
Lines changed: 33 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
| 14 | + | |
14 | 15 | | |
15 | 16 | | |
16 | 17 | | |
| |||
884 | 885 | | |
885 | 886 | | |
886 | 887 | | |
| 888 | + | |
887 | 889 | | |
888 | 890 | | |
889 | 891 | | |
| |||
916 | 918 | | |
917 | 919 | | |
918 | 920 | | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
919 | 926 | | |
920 | 927 | | |
921 | 928 | | |
| |||
939 | 946 | | |
940 | 947 | | |
941 | 948 | | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
942 | 953 | | |
943 | 954 | | |
944 | 955 | | |
| |||
Lines changed: 51 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
| 3 | + | |
2 | 4 | | |
3 | 5 | | |
4 | 6 | | |
5 | 7 | | |
6 | 8 | | |
| 9 | + | |
7 | 10 | | |
8 | 11 | | |
9 | 12 | | |
| |||
12 | 15 | | |
13 | 16 | | |
14 | 17 | | |
| 18 | + | |
15 | 19 | | |
16 | 20 | | |
17 | 21 | | |
| |||
185 | 189 | | |
186 | 190 | | |
187 | 191 | | |
188 | | - | |
| 192 | + | |
189 | 193 | | |
190 | 194 | | |
191 | 195 | | |
192 | 196 | | |
193 | | - | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
194 | 200 | | |
195 | 201 | | |
196 | | - | |
197 | | - | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
198 | 212 | | |
199 | 213 | | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
200 | 239 | | |
201 | 240 | | |
202 | 241 | | |
| |||
232 | 271 | | |
233 | 272 | | |
234 | 273 | | |
235 | | - | |
| 274 | + | |
236 | 275 | | |
237 | 276 | | |
238 | 277 | | |
| |||
248 | 287 | | |
249 | 288 | | |
250 | 289 | | |
251 | | - | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
252 | 293 | | |
253 | 294 | | |
254 | 295 | | |
255 | 296 | | |
256 | 297 | | |
257 | 298 | | |
258 | 299 | | |
259 | | - | |
| 300 | + | |
260 | 301 | | |
261 | | - | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
262 | 305 | | |
263 | 306 | | |
264 | 307 | | |
| |||
Lines changed: 39 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| 5 | + | |
5 | 6 | | |
6 | 7 | | |
7 | 8 | | |
| |||
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
Lines changed: 16 additions & 23 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
111 | 111 | | |
112 | 112 | | |
113 | 113 | | |
114 | | - | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | | - | |
119 | | - | |
120 | | - | |
121 | | - | |
122 | | - | |
123 | | - | |
124 | | - | |
125 | | - | |
126 | | - | |
127 | 114 | | |
128 | 115 | | |
129 | 116 | | |
| |||
143 | 130 | | |
144 | 131 | | |
145 | 132 | | |
146 | | - | |
147 | | - | |
148 | | - | |
149 | | - | |
150 | | - | |
151 | | - | |
152 | | - | |
153 | | - | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
154 | 141 | | |
155 | | - | |
156 | | - | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
157 | 149 | | |
158 | 150 | | |
159 | 151 | | |
| |||
167 | 159 | | |
168 | 160 | | |
169 | 161 | | |
| 162 | + | |
170 | 163 | | |
171 | 164 | | |
172 | 165 | | |
| |||
0 commit comments