Skip to content

Commit ef38ef2

Browse files
authored
Merge pull request #862 from DataIntegrationGroup/fix/duplicate-rows-in-batch
fix(ingestion): stop duplicate instants reaching one INSERT
2 parents dac1519 + 1405ce9 commit ef38ef2

4 files changed

Lines changed: 67 additions & 7 deletions

File tree

automated_ingestion/ocotillo/loader.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ def load_observations(
119119
table = TransducerObservation.__table__
120120

121121
for batch in _batched(records, batch_size):
122+
result.rows_seen += len(batch)
123+
124+
# One row per instant within a statement. Postgres refuses an
125+
# ON CONFLICT DO UPDATE that would touch the same row twice in one
126+
# command, and a source can repeat a reading -- overlapping fetch
127+
# windows, or a vendor logging the same instant twice. Keeping the last
128+
# occurrence matches the upsert's own rule: a later value wins.
129+
deduplicated = {record.observation_datetime: record for record in batch}
122130
rows = [
123131
{
124132
"deployment_id": deployment_id,
@@ -128,9 +136,10 @@ def load_observations(
128136
"release_status": release_status,
129137
"data_maturity": data_maturity,
130138
}
131-
for record in batch
139+
for record in deduplicated.values()
132140
]
133-
result.rows_seen += len(rows)
141+
if not rows:
142+
continue
134143

135144
statement = insert(table).values(rows)
136145
# DO UPDATE rather than DO NOTHING: a vendor may correct a reading, and

automated_ingestion/shared/windows.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,17 @@ def iter_windows(start: int, end: int, span: int = DEFAULT_SPAN) -> Iterator[Win
7474
raise ValueError(f"Window span must be positive, got {span}.")
7575
if end < start:
7676
raise ValueError(f"End {end} precedes start {start}.")
77+
# Windows must not share a boundary. Diver-HUB's ranges are inclusive at
78+
# both ends -- "from start time up to and including end time" -- so
79+
# [0, span] and [span, 2*span] both return the reading logged exactly at
80+
# `span`. That duplicate reaches the loader in one batch and Postgres
81+
# rejects the statement: "ON CONFLICT DO UPDATE command cannot affect row a
82+
# second time".
7783
cursor = start
7884
while cursor < end:
79-
yield Window(cursor, min(cursor + span, end))
80-
cursor += span
85+
chunk_end = min(cursor + span, end)
86+
yield Window(cursor, chunk_end)
87+
cursor = chunk_end + 1
8188

8289

8390
# ============= EOF =============================================

automated_ingestion/tests/test_windows.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,32 @@
2525
)
2626

2727

28-
def test_windows_cover_the_range_without_gaps_or_overlap():
28+
def test_windows_do_not_share_a_boundary():
29+
# Diver-HUB ranges are inclusive at both ends, so touching windows both
30+
# return the reading logged exactly on the boundary. That duplicate reaches
31+
# the loader in one batch and Postgres rejects the statement: "ON CONFLICT
32+
# DO UPDATE command cannot affect row a second time".
2933
windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
3034
assert windows[0].start == 0
3135
assert windows[-1].end == 10 * DAY
3236
for earlier, later in zip(windows, windows[1:]):
33-
assert earlier.end == later.start
37+
assert later.start == earlier.end + 1
38+
39+
40+
def test_windows_leave_no_second_uncovered():
41+
# The gap is exactly one second and timestamps are second-resolution, so
42+
# nothing can fall between two windows.
43+
windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
44+
for earlier, later in zip(windows, windows[1:]):
45+
assert later.start - earlier.end == 1
3446

3547

3648
def test_final_window_is_truncated_not_overshot():
3749
# Overshooting would ask the API for a future range, which is at best waste
3850
# and at worst a 400.
3951
windows = list(iter_windows(0, 10 * DAY, span=3 * DAY))
4052
assert windows[-1].end == 10 * DAY
41-
assert windows[-1].span == DAY
53+
assert all(w.end <= 10 * DAY for w in windows)
4254

4355

4456
def test_range_shorter_than_span_is_a_single_window():

tests/test_transducer_loader.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,4 +268,36 @@ def test_rows_with_no_recorded_maturity_still_update(loader_target):
268268
assert value == 99.0
269269

270270

271+
def test_duplicate_instants_in_one_batch_do_not_break_the_statement(loader_target):
272+
"""Reproduces the first live run's failure.
273+
274+
Postgres rejects an ON CONFLICT DO UPDATE that would touch the same row
275+
twice in one command:
276+
277+
ON CONFLICT DO UPDATE command cannot affect row a second time
278+
279+
The existing idempotency test loads the same window in two separate
280+
statements, which Postgres allows, so it could not catch this. A source can
281+
repeat an instant -- overlapping fetch windows did, and a vendor may log one
282+
twice.
283+
"""
284+
deployment_id, parameter_id = loader_target
285+
duplicated = _records(3) + _records(3, value=99.0)
286+
287+
with session_ctx() as session:
288+
result = load_observations(
289+
session, duplicated, deployment_id, parameter_id, "draft"
290+
)
291+
assert _count(session, deployment_id) == 3
292+
assert result.rows_seen == 6
293+
294+
# The later value wins, matching the upsert's own rule.
295+
values = session.scalars(
296+
select(TransducerObservation.value)
297+
.where(TransducerObservation.deployment_id == deployment_id)
298+
.order_by(TransducerObservation.observation_datetime)
299+
).all()
300+
assert values[0] == 99.0
301+
302+
271303
# ============= EOF =============================================

0 commit comments

Comments
 (0)