Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Object-store history retention tests no longer expire with the wall clock** ([#1066](https://github.com/drt-hub/drt/issues/1066)): prune/cap fixtures now derive ordered run timestamps from the test-run time instead of hardcoding August 2026 dates, preventing clean `main` CI from changing its expected removed count as the calendar advances.

- **BigQuery, Airtable, and Klaviyo now pass `drt validate --check-connection` with their documented least-privilege credentials** ([#1059](https://github.com/drt-hub/drt/issues/1059)): all three connectors were excluded from #1049's structural `ConnectionTestable` dispatch because their probes needed broader API scope than each connector's own docs recommend as the minimal write-only credential. Fixed with a real least-privilege probe per API, verified against each vendor's current documentation rather than guessed: BigQuery's `test_connection()` now calls `get_table()` (needs only `bigquery.tables.get`, already included in the `roles/bigquery.dataEditor` role that grants the documented `bigquery.tables.updateData`) instead of running a `SELECT 1` query job (needs the separate, broader `bigquery.jobs.create`). Airtable's now calls `GET /v0/meta/whoami`, Airtable's own documented scope-free identity endpoint, instead of reading a record (`data.records:read`). Klaviyo has no scope-free identity endpoint, so it still probes `GET /accounts/` but now also accepts Klaviyo's documented `403` + `code: "permission_denied"` response as proof of a valid (if more narrowly scoped) key, rather than treating every non-2xx as a failure — any other error still raises normally. All three are back in the normal `ConnectionTestable` dispatch; no exclusion list needed.

- **Two more `drt run --dry-run --diff` preview correctness bugs, both pre-existing before #1044** ([#1061](https://github.com/drt-hub/drt/issues/1061), [#1062](https://github.com/drt-hub/drt/issues/1062)): (1) the tracked-mirror preview ignored `sync.mirror.scope` entirely — a run touching one tenant/parent could preview another tenant's stale keys as deletions, contradicting what the real `_finalize_mirror_tracked()` actually does. Now filters prior state to the scopes this run's source records actually produced, matching the real finalizer, across all five SQL dialects; unscoped tracked mirror is unaffected. (2) `compute_diff()`'s replace-mode full-table scan calls `fetch_rows(..., columns=[])`, and Postgres/MySQL/Snowflake/ClickHouse's readers built result dicts via `dict(zip([], row))` — always `{}` — silently collapsing every destination row into one keyless entry and severely understating a real `replace` run's deletions (Databricks was already fixed for this in #1044). All four now derive column names from query metadata (`cursor.description` / ClickHouse's `column_names`) when `columns` is empty, matching Databricks' existing fix.
Expand Down
26 changes: 17 additions & 9 deletions tests/unit/test_state_objectstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import logging
import threading
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -257,12 +257,16 @@ def test_history_append_non_precondition_error_warns_without_retry(
def test_remote_history_applies_entry_cap_only_during_prune() -> None:
client = MemoryObjectClient()
store = ObjectStoreHistoryStore(client, max_entries=2)
for day in range(1, 4):
store.append(_history(started_at=f"2026-08-0{day}T00:00:00+00:00"))
now = datetime.now(timezone.utc)
timestamps = [
(now - timedelta(days=days_ago)).isoformat() for days_ago in (3, 2, 1)
]
for started_at in timestamps:
store.append(_history(started_at=started_at))

assert len(store.read("s", limit=20)) == 3
assert store.prune("s", retention_days=30) == 1
assert [entry.started_at[9] for entry in store.read("s")] == ["3", "2"]
assert [entry.started_at for entry in store.read("s")] == timestamps[::-1][:2]


def test_prune_caps_by_started_at_not_append_order() -> None:
Expand All @@ -274,14 +278,18 @@ def test_prune_caps_by_started_at_not_append_order() -> None:
"""
client = MemoryObjectClient()
store = ObjectStoreHistoryStore(client, max_entries=2)
# Appended oldest-started-at last, as if a later-starting run's batch
now = datetime.now(timezone.utc)
oldest = (now - timedelta(days=3)).isoformat()
middle = (now - timedelta(days=2)).isoformat()
newest = (now - timedelta(days=1)).isoformat()
# Append out of started_at order, as if a later-starting run's batch
# observer flushed and completed before an earlier-starting run did.
store.append(_history(started_at="2026-08-03T00:00:00+00:00"))
store.append(_history(started_at="2026-08-01T00:00:00+00:00"))
store.append(_history(started_at="2026-08-02T00:00:00+00:00"))
store.append(_history(started_at=newest))
store.append(_history(started_at=oldest))
store.append(_history(started_at=middle))

assert store.prune("s", retention_days=30) == 1
assert [entry.started_at[9] for entry in store.read("s")] == ["3", "2"]
assert [entry.started_at for entry in store.read("s")] == [newest, middle]


def test_history_reads_all_syncs_newest_first_and_prunes_old_entries() -> None:
Expand Down
Loading