Skip to content

feat: add reusable offline price gap patches - #103

Merged
protostatis merged 1 commit into
mainfrom
fix/offline-price-gap-patch
Aug 13, 2026
Merged

protostatis merged 1 commit into
mainfrom
fix/offline-price-gap-patch

Conversation

@protostatis

Copy link
Copy Markdown
Owner

Summary

  • add strict, spec-driven CoinGecko outage patch collection and sealed artifacts
  • add transactional apply, manifest migration, read-only inspection, and guarded rollback
  • preserve compatibility with the August 2026 artifact and production manifest
  • document the reusable workflow and historical production application

Safety

  • collection accepts no target database path
  • apply requires exact artifact/spec/count/incident/target confirmations and paused writers
  • target mutation is all-or-none under BEGIN IMMEDIATE with a verified pre-mutation backup
  • existing candidate buckets are hard conflicts; no overwrite/upsert path exists
  • artifact candidates are loaded from the same private snapshot that passed validation
  • pre-existing foreign-key violations must remain exactly unchanged

Verification

  • uv run pytest -q — 234 passed, 1 skipped
  • focused maintenance Ruff checks — passed
  • wheel build and packaged legacy spec import — passed
  • secret/path/IP scan of added files — clean

@protostatis protostatis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sky's Code Review

This PR adds a reusable, spec-driven offline CoinGecko price-gap patch toolkit (collection, sealed artifacts, transactional apply, manifest migration, read-only inspection, and guarded rollback). It is careful, defensive database-mutation tooling rather than the Docker/shell configs this skill typically targets, so I reviewed it for security/correctness/reliability of the write path. The safety posture is strong: collection never touches a production DB, apply/rollback require exact multi-field confirmations, writers-paused checks, BEGIN IMMEDIATE transactions, force-verified pre-mutation backups, foreign-key baseline tracking, and all-or-none commit. I found no secrets, no glaring security holes, and no production-breaking bug in the visible portion. The diff was truncated (4 files not shown, likely tests/docs/fixtures), so the 234-pass test claim and the remaining files were not independently verifiable. Verdict is comment: a handful of robustness/correctness nits worth addressing, none blocking.

Verdict: Comment

Comments

  • The diff is truncated ('... 4 more file(s) truncated due to size'). The remaining files are almost certainly tests/docs/fixtures, but the 234-pass test claim, the wheel build, and the packaged legacy-spec import could not be verified from this diff alone. Please confirm the truncated files don't contain any executable/CI changes.
  • rollback_patch opens a read-write connection (_readwrite_connection) and starts BEGIN IMMEDIATE before validating the confirmation flags (_guard_mutation runs after acquiring the write lock). This is not a correctness bug, but validating the confirmations before opening the write lock would avoid briefly taking the production write lock on a command that will just fail confirmation. Same ordering observation applies between classify_target and the earlier confirmation checks in apply_patch (apply_patch does confirm before opening, which is the better pattern).
  • The pre-mutation backup in apply_patch is taken while holding BEGIN IMMEDIATE (RESERVED/write lock) but is produced through a separate read-only connection via the SQLite online-backup API. This is durable and consistent today, but it depends on the subtle invariant that no data mutation happens before _backup_database. A short comment asserting 'backup must be the first data-affecting step after classify' would protect that ordering from future refactors.
  • _migrate_manifest_to_v2 relies on transactional DDL (ALTER TABLE ... RENAME / DROP TABLE / CREATE TABLE inside the open transaction) and asserts connection.in_transaction after each step. This is correct on modern SQLite but the module should document the minimum SQLite version requirement (>=3.25), since on older versions some DDL would implicitly commit and silently break the all-or-none guarantee.
  • Exact float equality is also used implicitly in _candidate_digest (hash of canonical JSON of floats) and in artifact reproduce checks. Because the JSON bytes are serialized deterministically from the same Python floats, this is self-consistent and fine within one process/version — just avoid regenerating fixtures across Python/SQLite versions without re-baselining the pinned hashes.
  • Overall the safety design (no DB path in collection, exact confirmations, BEGIN IMMEDIATE, verified backup, FK baseline, all-or-none commit, migrated manifest compatibility with the Aug 2026 v1 artifact) is excellent and clearly meets its stated Safety goals.

Reviewed by Sky — Unchained Sky engineering agent

schema_version=schema_version,
incident_id=incident_id,
provider=provider,
source_tag=source_tag,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

source_tag length is guarded against >50, but that 50-char limit is a bare magic number with no constant or citation to the actual price_data source column length. If the real column ever differs, this check drifts silently. Extract it to a named constant and/or derive it from the schema.

"start_hour": self.start_hour.isoformat(),
"end_exclusive": self.end_exclusive.isoformat(),
"coins": dict(self.coins),
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch_start/fetch_end pad one hour on each side of the gap. Worth a comment documenting that CoinGecko's market_chart/range returns data keyed to the bucket start, so the padding is required to correctly reconstruct the first/last full UTC hours. It is a subtle correctness assumption that is easy to break later.

f"Artifact metadata mismatch for {key}: {metadata.get(key)!r} != {expected!r}"
)

coin_rows = connection.execute(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_value_matches uses exact == on floats for price_usd/volume_24h/market_cap verification. Exact float equality on DB round-tripped REAL values is fragile — a value that survives an insert/select round-trip today could differ by 1 ULP under a different SQLite build or driver and cause a spurious 'applied row no longer matches' failure on verify/rollback of an otherwise-intact row. Consider a tolerance (math.isclose or a small relative epsilon) while still failing loudly on material divergence.

f"{coin.lower()}-attempt-{attempt.attempt}-"
f"http-{attempt.http_status}.{suffix}"
)
response_path = temporary / name

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAX_RESPONSE_BYTES is enforced only after response.content has already been fully buffered into memory (httpx reads the body before this check). The 20 MiB cap therefore bounds stored size/disk but not peak memory. If protecting memory is a goal, stream or cap via a client-side streaming read instead of buffering content first.

f"got {len(successful_attempts)}"
)
successful_raw_id = None
for attempt in attempts:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retryable status logic maps http_status None to retryable, but a timeout/HTTPError (attempt.http_status is None) uses the same exponential backoff path as transient 5xx. That is fine, but note there is no retry budget/pause on a persistent non-retryable 400/401/403 — it correctly breaks immediately, which is the right behavior for a wrong API key or bad coin id.

@protostatis
protostatis merged commit ff2dd80 into main Aug 13, 2026
3 checks passed
@protostatis
protostatis deleted the fix/offline-price-gap-patch branch August 13, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant