PR 3 — Core data model - #24
Conversation
Migrate and model the seven tables from IMPLEMENTATION_PLAN.md §7, so every later PR has a durable place to write. PostgreSQL is the sole source of business truth (§8), so these constraints are the durability and idempotency guarantees rather than decoration around them. Schema, one migration per table in dependency order: github_actors and github_repositories with the shared enrichment state columns and a partial index matching the reconciler's exact predicate; event_sources with the four scheduling components as separate columns (§9 — collapsing them would leave --force unable to identify which constraint it may bypass); github_api_budget as a schema-enforced singleton (CHECK (id = 1)) carrying window fields and per-class counters, with no stored class-block timestamp because §10 derives class blocking from the counters; ingestion_runs; push_events with every structured column NOT NULL and foreign keys onto the entity tables' github_id; quarantined_events with the payload fingerprint as its sole unique identity and github_event_id indexed but not unique. Three idempotent write paths ship as tested model primitives: PushEvent.insert_if_new (ON CONFLICT DO NOTHING RETURNING id — the nil return is what tells the ingest transaction a replay must not register activity), upsert_stub! on both entity types (identity refresh that can never clear an enrichment payload, name, or a skipped_budget state), and QuarantinedEvent.record! (occurrence counting). Enrichable.touch_activity! writes the activity fields; PR 5 owns gating it on a newly inserted row. Every timestamp that could be written out of order is monotonic. Delayed events carry an older created_at (documented 30s-6h latency) and sources commit independently, so plain assignment could move last_seen_at or last_received_at backwards and distort newest-first enrichment ordering. CI now runs the real suite (§13): a test job with a postgres:16 service and Ruby pinned from .ruby-version. bin/ci gains the matching steps; it ran no tests before. Closes #13 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first CI run reported "libpq-dev is already the newest version" — the ubuntu-latest image ships it, so the step never did anything. If it ever disappears from the image, the pg gem build fails loudly and this comes back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| t.text :github_event_id | ||
| t.text :payload_fingerprint, null: false | ||
| t.text :event_type | ||
| t.jsonb :raw_payload, null: false |
There was a problem hiding this comment.
A parsed payload is not necessarily non-null. For example, a valid events array could contain a null element, which should fall into the invalid-envelope quarantine path. Rails serializes that Ruby value as SQL NULL, so this constraint makes record!(raw_payload: nil) raise ActiveRecord::NotNullViolation instead of preserving the malformed event. The model presence validation also rejects empty objects and arrays, which are valid parsed values for an invalid envelope. Could we allow these values here and add coverage for null and empty envelopes?
|
|
||
| def self.upsert_stub!(github_id:, login:, display_login: nil, api_url: nil, | ||
| avatar_url: nil, now: Time.current) | ||
| upsert( |
There was a problem hiding this comment.
This upsert reaches PostgreSQL without validating the required stub fields. A nil login currently raises ActiveRecord::NotNullViolation, which aborts the current transaction before the caller can route the malformed envelope to quarantine. Could we validate or guard the required fields before issuing the upsert, similar to PushEvent.insert_if_new? The repository stub has the same issue with github_id and full_name.
| last_received_at = GREATEST(quarantined_events.last_received_at, | ||
| EXCLUDED.last_received_at), | ||
| occurrence_count = quarantined_events.occurrence_count + 1, | ||
| updated_at = EXCLUDED.updated_at |
There was a problem hiding this comment.
The GREATEST above keeps last_received_at monotonic, but this assignment still moves updated_at backward for the same late-replay case. Recording t + 300 and then t leaves last_received_at at t + 300 while setting updated_at back to t, even though occurrence_count was just updated. Should this use GREATEST(quarantined_events.updated_at, EXCLUDED.updated_at) as well?
quarantined_events.raw_payload is nullable again. The NOT NULL justification
conflated two distinct taxonomy rows in §7: a wholly unparseable response body is
an ingestion failure, but a valid events array can contain null, {}, or [] as an
element — each a parsed value with no usable envelope, and each one §7 says to
quarantine. NOT NULL turned exactly those into a NotNullViolation that aborts the
ingest transaction and destroys the malformed event quarantine exists to
preserve. raw_payload also leaves the presence validation, which rejected {} and
[] as blank for the same reason. payload_fingerprint stays NOT NULL: the
canonical fingerprint of null is as well defined as any other.
upsert_stub! now validates before issuing the upsert, mirroring
PushEvent.insert_if_new. It reached PostgreSQL directly, so a nil github_id,
login, or full_name surfaced as a NotNullViolation that aborted the transaction
before the caller could route the malformed envelope to quarantine.
updated_at is monotonic in all three merge rules. GREATEST already protected
last_received_at, but the same late replay still dragged updated_at backwards
while the row was being updated. touch_activity! already used GREATEST for
updated_at, so the merges were the inconsistent ones.
Coverage added: null, empty-object, and empty-array envelopes preserved and
occurrence-counted; both stub guards rejecting a malformed envelope with nothing
written; updated_at monotonicity on all three paths; raw_payload nullability
asserted against the schema. 126 examples, 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # actor.display_login -> display_login, actor.url -> api_url, | ||
| # actor.avatar_url -> avatar_url. | ||
| IDENTITY_MERGE = <<~SQL.squish | ||
| login = EXCLUDED.login, |
There was a problem hiding this comment.
The timestamp now stays monotonic, but the identity fields can still move backward. I tried writing new-login at t + 300 followed by old-login at t; the row ended up with old-login while updated_at remained t + 300. The repository merge behaves the same way. That leaves the timestamp claiming the newer observation while storing identity from the older one, which can happen when source transactions commit out of order. Could we guard these assignments using EXCLUDED.updated_at >= the existing updated_at, apply the same rule to the optional identity fields and repository merge, and add a regression test where the values actually differ?
Making updated_at monotonic without doing the same for the identity fields left
the two inconsistent: writing new-login at t + 300 then old-login at t stored
old-login while updated_at stayed at t + 300, so the timestamp claimed the newer
observation while the row held the older one. Sources commit independently and
events arrive late (documented 30s-6h latency), so this is reachable.
Every assignment in both IDENTITY_MERGEs now has one shape:
COALESCE(CASE WHEN EXCLUDED.updated_at >= <table>.updated_at
THEN EXCLUDED.col END,
<table>.col)
A stale envelope makes the CASE yield NULL and COALESCE keeps the stored value, so
an older observation cannot overwrite newer identity. A fresh envelope that omits
an optional field yields NULL too, so the same expression still stops a sparse
envelope from blanking a known value — the COALESCE that was already there. The
comparison is >= so two observations sharing an instant let the later write win
instead of being discarded as stale.
QuarantinedEvent::OCCURRENCE_MERGE needs no equivalent: it overwrites no identity
fields, since error_code and error_message are deliberately not refreshed.
Regression coverage on both entities with values that actually differ, plus the
same-instant boundary. Verified the new spec fails with the guard removed
(got "old-login") and passes with it. 130 examples, 0 failures.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull Request
Linked issue
Closes #13 (PR 3 — Core data model;
IMPLEMENTATION_PLAN.md§13 "PR 3"). Parent story: #6. Related tracks: #7 (Story 3 — Enrich Push Events), #10 (Extension B — Idempotency and Restart Safety).Problem
The app booted, reported health, and logged JSON — but had zero business tables.
db/schema.rbwas at version 0 anddb/migrate/did not exist. Plan §8 makes PostgreSQL the sole source of business truth (an event is accepted only once itspush_eventsrow commits), so every capability from PR 4 onward needs this schema first.Issue #13 also carries a delivery gate: CI must run the real suite from this PR onward. It previously ran only
test -ffile-existence checks — no Ruby, no PostgreSQL, norspec— andbin/ciran no tests either.Scope
Included:
github_actors,github_repositories,event_sources,github_api_budget,ingestion_runs,push_events,quarantined_events(§7)event_sourcesscheduling components as four separate columns (cadence_due_at,poll_floor_until,retry_not_before_at,next_poll_at) — §9 forbids collapsing them, or--forcecould not tell which constraint it may bypassgithub_api_budgetas a schema-enforced singleton (id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1)) with window fields and per-class counterspush_eventswith every structured columnNOT NULL, SHAs asvarchar(64), raw payload asjsonb, and FKs onto the entity tables' uniquegithub_idquarantined_eventswithpayload_fingerprintas the sole unique identity andgithub_event_idindexed but not uniquePushEvent.insert_if_new,upsert_stub!on both entities,QuarantinedEvent.record!, plusEnrichable.touch_activity!Deliberately not included (later PRs per §13):
RequestExecutor,SourceLock,RequestGate,UrlPolicy, transports, fixture corpus,BudgetLedgerreservation + allowance formula + bootstrap, budget seed row, counter arithmetic invariants,webmock/faradayPushEventparsing, the SHA-256 fingerprint algorithm, quarantine taxonomy routing, ingest-transaction orchestration (gatingtouch_activity!oninsert_if_new),ingestion_runs.statusvocabulary, one-shot command304debit,effective_poll_time, global-vs-class blocking, event-source seeding,event_sources.statusvocabularyskipped_budgetreactivation, fairness shares, refresh TTLssolid_queue, worker, jobs, reconciler/statusraw_payloadNo
Gemfilechange. §2A pins the test stack to RSpec + WebMock + hand-authored fixtures;factory_botandshoulda-matchersare not in it, so test data is built by explicit helpers inspec/support/. WebMock is pinned but there is no HTTP in PR 3.Technical decisions
touch_activity!andrecord!useGREATEST/LEASTrather than plain assignment. Delayed events carry an oldercreated_at(documented 30s–6h latency) and sources commit independently, so assignment could movelast_seen_atorlast_received_atbackwards and distort newest-first enrichment ordering. Forquarantined_events.last_received_atthis is a deliberate hardening of §7's snippet: it implements the column's stated meaning ("most recent observation") more faithfully than the literalEXCLUDEDassignment.insert_if_newvalidates explicitly.insertbypasses Active Record validations, so without an explicitvalidate!the 40-or-64-hex SHA rule would never run on the real write path — a decorative validation. No DB regex CHECK: §7's enumerated constraint list has none, and aStatementInvalidmid-batch is exactly what quarantine exists to avoid. PR 5's parser remains the routing point.upsert_stub!'sSETlist leaves outraw_payload,name/description/language, and everyenrichment_*column, so an envelope replay cannot clear enrichment or reactivate askipped_budgetentity (§7 rules 1 and 4).COALESCEstops a sparse envelope from blanking a known value. Verified by spec: an entity withskipped_at,enrichment_attempts,next_retry_at, andlast_errorset survives a changed-login replay with all four intact.insert,upsert,update_all) bypass validations, so counter floors are enforced in the schema. Reservation arithmetic (used <= allowance, reserve headroom) is deliberately not constrained — that is PR 4's to define, and encoding it now would turn a benign transient into a batch-aborting error.enrichment_statusistext+ CHECK, not a native enum. A CHECK round-trips cleanly throughschema.rb(whichdb:test:preparereloads) and widening it later is a constraint swap rather thanALTER TYPE.textrather thanvarcharalso keeps PostgreSQL from injecting a::textcast into the partial-index predicate.event_sources/ingestion_runs. Neither is defined anywhere in the plan, and the owning PR (6 and 5) owns the state machine. Both aretext NOT NULLwith no default and no CHECK. Note the trap avoided: an obvious-lookingrate_limitedsource status would re-introduce per-source rate-limit state, which V2 explicitly moved to the global ledger (§7).insert!, notinsert.insert_allhard-codeson_duplicate: :skip, so a uniqueness test written withinsertwould silently never raise. Every expected violation names its specific error class —ActiveRecord::StatementInvalidis the superclass of all of them, so a broad assertion could pass on the wrong failure.use_transactional_fixturesa failed statement aborts the example's transaction and every later query raisesPG::InFailedSqlTransaction.spec/support/constraint_helpers.rbwraps each violation intransaction(requires_new: true).IN (...)to= ANY (ARRAY[...]), so a predicate string comparison is brittle; andRelation#explainreturns anExplainProxy, while on a seed-sized table the planner prefers a sequential scan anyway. The specs check index shape and predicate membership (driven off theEnrichableconstants) plus scope behaviour across all five statuses.factory_bot/shoulda-matchers(outside §2A's pinned stack); a native PostgreSQL enum type; a GIN index onraw_payload(§7 gates it on a demonstrated query); DB-level SHA and timestamp-ordering CHECKs; deferringtouch_activity!wholesale to PR 5 — its effect is testable in isolation now, and only its gate depends on PR 5.Testing performed
docker compose run --rm test— 130 examples, 0 failures (121 new; the 9 PR 2 health/formatter examples still pass)DO NOTHING RETURNING id; raw JSON retention asserted as semantic equivalence, with one spec asserting the byte difference explicitly so the tradeoff is visible in the suite; required fields/types/NOT NULL; stub merge rules including theskipped_budgetnon-reactivation case; fingerprint as sole unique key (same event ID + two payloads coexist) and occurrence counting; pending-status queries against the partial indexesspec/db/schema_spec.rbguarding the ledger's exact column set and the absence of any*_class_blocked_untilcolumnbundle exec rubocop: 51 files, no offenses.bin/brakeman: 0 security warningsRSpec suitejob resolved Ruby 3.4.10 from.ruby-version, prepared both test databases, and ran 130 examples / 0 failures. Alibpq-devinstall step was added defensively, then removed once the first run proved the runner already ships it.Docker verification
db:prepare— all seven migrations applied in order, both FKs bound togithub_idschema.rbround-trip confirmed — the dump preservesid: :integer, default: 1andt.check_constraint "id = 1". This was the gate to watch:db:test:prepareloads fromschema.rb, so an un-dumpable construct would vanish in test. The budget singleton spec passing underdocker compose run --rm testproves it survivedINSERT ... VALUES (2)→violates check constraint "github_api_budget_singleton";id = 1accepted... WHERE (enrichment_status = ANY (ARRAY['pending'::text, 'retryable_failure'::text]))— no cast on the column, because the type istextdb:rollback:primary STEP=7unwinds all seven in reverse FK order to a clean database, thendb:migratere-applies all seven. (Note for reviewers: this app is multi-database, so baredb:rollbackaborts and directs you to the namespaced task.)Documentation updates
docs/adr/0001-jsonb-semantic-retention.md— the one architectural decision this PR makes, recording thatjsonbdiscards whitespace, key order, and duplicate keys; that array order is preserved; and that a byte-exacttextcolumn is deliberately not built. README: status, reviewer-commands note, planned-contents table, and a pointer todocs/adr/.db/queue_schema.rbis deliberately unchanged —db:preparere-dumps it and clobbers PR 2's explanatory comment, which is unrelated noise in a data-model PR.Review round 1
Three findings, all fixed in 44df326:
quarantined_events.raw_payloadwasNOT NULL. The justification conflated two separate rows of §7's taxonomy: a wholly unparseable response body is an ingestion failure, but a valid events array can containnull,{}, or[]as an element — each a parsed value with no usable envelope that §7 says to quarantine.NOT NULLturned exactly those into aNotNullViolationaborting the ingest transaction, destroying the malformed event quarantine exists to preserve. Now nullable, and off the presence validation too (presencetreats{}/[]as blank).payload_fingerprintstaysNOT NULL— the canonical fingerprint ofnullis as well defined as any other.upsert_stub!reached PostgreSQL unguarded — the same defect already fixed ininsert_if_new, missed here. A nilgithub_id/login/full_nameaborted the transaction before the caller could quarantine one envelope. Both stubs nowvalidate!first, backed by real validations (github_idin theEnrichableconcern,loginon the actor,full_nameon the repository). No DB round trip is added, since there is no uniqueness validation.updated_atregressed on a late replay.GREATESTprotectedlast_received_atbut notupdated_at, and both entityIDENTITY_MERGEs had the same latent bug. All three merges are now monotonic, matchingtouch_activity!, which already did this — the merges were the inconsistent ones.Review round 2
One finding, fixed in c651c3f: the identity fields could still move backward. Making
updated_atmonotonic in round 1 without applying the same rule to the identity assignments left the two inconsistent — writingnew-loginatt + 300thenold-loginattstoredold-loginwhileupdated_atstayed att + 300, so the timestamp claimed the newer observation while the row held the older one. Reachable because sources commit independently and events arrive late.Both
IDENTITY_MERGEs now use one expression per assignment, which collapses the freshness guard and the existing sparse-envelope guard into a single mechanism rather than adding a second:A stale envelope makes the
CASEyieldNULLsoCOALESCEkeeps the stored value; a fresh envelope omitting an optional field also yieldsNULL, preserving the original behaviour.>=lets a same-instant write win rather than being discarded.QuarantinedEvent::OCCURRENCE_MERGEneeds no equivalent — it overwrites no identity fields.Regression tests on both entities use values that actually differ and assert
updated_at, plus a same-instant boundary case. Verified non-vacuous: with the guard removed the actor spec fails withgot: "old-login"; with it restored it passes.Known limitations
touch_activity!is ungated by design — nothing yet enforces that it is called only for a newly inserted event. That gate, and the spec proving it, belong to PR 5.poll_used > poll_allowance. PR 4's ledger owns reservation arithmetic.event_sources.statusandingestion_runs.statusaccept any text until PRs 6 and 5 define their vocabularies.