Skip to content

PR 3 — Core data model - #24

Merged
batbrainy merged 4 commits into
mainfrom
pr-3-core-data-model
Jul 30, 2026
Merged

PR 3 — Core data model#24
batbrainy merged 4 commits into
mainfrom
pr-3-core-data-model

Conversation

@batbrainy

@batbrainy batbrainy commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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.rb was at version 0 and db/migrate/ did not exist. Plan §8 makes PostgreSQL the sole source of business truth (an event is accepted only once its push_events row 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 -f file-existence checks — no Ruby, no PostgreSQL, no rspec — and bin/ci ran no tests either.

Scope

Included:

  • Seven migrations, one per table, dependency-orderedgithub_actors, github_repositories, event_sources, github_api_budget, ingestion_runs, push_events, quarantined_events (§7)
  • event_sources scheduling components as four separate columns (cadence_due_at, poll_floor_until, retry_not_before_at, next_poll_at) — §9 forbids collapsing them, or --force could not tell which constraint it may bypass
  • github_api_budget as a schema-enforced singleton (id integer PRIMARY KEY DEFAULT 1 CHECK (id = 1)) with window fields and per-class counters
  • push_events with every structured column NOT NULL, SHAs as varchar(64), raw payload as jsonb, and FKs onto the entity tables' unique github_id
  • quarantined_events with payload_fingerprint as the sole unique identity and github_event_id indexed but not unique
  • Partial index on both entity tables matching the reconciler's exact predicate
  • Three idempotent write paths as tested primitives: PushEvent.insert_if_new, upsert_stub! on both entities, QuarantinedEvent.record!, plus Enrichable.touch_activity!
  • 109 new specs (118 total); CI runs the real suite; one ADR

Deliberately not included (later PRs per §13):

Deferred Owner
RequestExecutor, SourceLock, RequestGate, UrlPolicy, transports, fixture corpus, BudgetLedger reservation + allowance formula + bootstrap, budget seed row, counter arithmetic invariants, webmock/faraday PR 4
Tolerant PushEvent parsing, the SHA-256 fingerprint algorithm, quarantine taxonomy routing, ingest-transaction orchestration (gating touch_activity! on insert_if_new), ingestion_runs.status vocabulary, one-shot command PR 5
Pagination, 304 debit, effective_poll_time, global-vs-class blocking, event-source seeding, event_sources.status vocabulary PR 6
Enrichment execution, state transitions, skipped_budget reactivation, fairness shares, refresh TTLs PR 7
solid_queue, worker, jobs, reconciler PR 8
Event inspection API, /status PR 10
GIN index on raw_payload when a query demonstrates the need (§7)
Remaining ADRs, design brief, full data-model reference PR 12

No Gemfile change. §2A pins the test stack to RSpec + WebMock + hand-authored fixtures; factory_bot and shoulda-matchers are not in it, so test data is built by explicit helpers in spec/support/. WebMock is pinned but there is no HTTP in PR 3.

Technical decisions

  • Monotonic activity timestamps. touch_activity! and record! use GREATEST/LEAST rather than plain assignment. Delayed events carry an older created_at (documented 30s–6h latency) and sources commit independently, so assignment could move last_seen_at or last_received_at backwards and distort newest-first enrichment ordering. For quarantined_events.last_received_at this is a deliberate hardening of §7's snippet: it implements the column's stated meaning ("most recent observation") more faithfully than the literal EXCLUDED assignment.
  • insert_if_new validates explicitly. insert bypasses Active Record validations, so without an explicit validate! 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 a StatementInvalid mid-batch is exactly what quarantine exists to avoid. PR 5's parser remains the routing point.
  • Merge rules omit rather than overwrite. upsert_stub!'s SET list leaves out raw_payload, name/description/language, and every enrichment_* column, so an envelope replay cannot clear enrichment or reactivate a skipped_budget entity (§7 rules 1 and 4). COALESCE stops a sparse envelope from blanking a known value. Verified by spec: an entity with skipped_at, enrichment_attempts, next_retry_at, and last_error set survives a changed-login replay with all four intact.
  • Non-negativity CHECKs only. The write paths (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_status is text + CHECK, not a native enum. A CHECK round-trips cleanly through schema.rb (which db:test:prepare reloads) and widening it later is a constraint swap rather than ALTER TYPE. text rather than varchar also keeps PostgreSQL from injecting a ::text cast into the partial-index predicate.
  • No status vocabulary for event_sources / ingestion_runs. Neither is defined anywhere in the plan, and the owning PR (6 and 5) owns the state machine. Both are text NOT NULL with no default and no CHECK. Note the trap avoided: an obvious-looking rate_limited source status would re-introduce per-source rate-limit state, which V2 explicitly moved to the global ledger (§7).
  • Constraint specs use insert!, not insert. insert_all hard-codes on_duplicate: :skip, so a uniqueness test written with insert would silently never raise. Every expected violation names its specific error class — ActiveRecord::StatementInvalid is the superclass of all of them, so a broad assertion could pass on the wrong failure.
  • Violations run inside a savepoint. Under use_transactional_fixtures a failed statement aborts the example's transaction and every later query raises PG::InFailedSqlTransaction. spec/support/constraint_helpers.rb wraps each violation in transaction(requires_new: true).
  • Partial-index specs assert semantics, not planner behaviour. PostgreSQL normalises IN (...) to = ANY (ARRAY[...]), so a predicate string comparison is brittle; and Relation#explain returns an ExplainProxy, while on a seed-sized table the planner prefers a sequential scan anyway. The specs check index shape and predicate membership (driven off the Enrichable constants) plus scope behaviour across all five statuses.
  • Rejected: factory_bot/shoulda-matchers (outside §2A's pinned stack); a native PostgreSQL enum type; a GIN index on raw_payload (§7 gates it on a demonstrated query); DB-level SHA and timestamp-ordering CHECKs; deferring touch_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 test130 examples, 0 failures (121 new; the 9 PR 2 health/formatter examples still pass)
  • Coverage maps to §12's "Persistence tests": unique event ID with 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 the skipped_budget non-reactivation case; fingerprint as sole unique key (same event ID + two payloads coexist) and occurrence counting; pending-status queries against the partial indexes
  • Also covered: the singleton at the DB level, negative-counter rejection on every counter, monotonicity in both directions, 40- and 64-char SHA acceptance with 39/41/non-hex rejection, and spec/db/schema_spec.rb guarding the ledger's exact column set and the absence of any *_class_blocked_until column
  • bundle exec rubocop: 51 files, no offenses. bin/brakeman: 0 security warnings
  • GitHub Actions: both jobs green. The RSpec suite job resolved Ruby 3.4.10 from .ruby-version, prepared both test databases, and ran 130 examples / 0 failures. A libpq-dev install step was added defensively, then removed once the first run proved the runner already ships it.

Docker verification

  • Clean apply: dropped the development databases and ran db:prepare — all seven migrations applied in order, both FKs bound to github_id
  • schema.rb round-trip confirmed — the dump preserves id: :integer, default: 1 and t.check_constraint "id = 1". This was the gate to watch: db:test:prepare loads from schema.rb, so an un-dumpable construct would vanish in test. The budget singleton spec passing under docker compose run --rm test proves it survived
  • Singleton proven directly in psql: INSERT ... VALUES (2)violates check constraint "github_api_budget_singleton"; id = 1 accepted
  • Partial index in psql: ... WHERE (enrichment_status = ANY (ARRAY['pending'::text, 'retryable_failure'::text])) — no cast on the column, because the type is text
  • Rollback safety: db:rollback:primary STEP=7 unwinds all seven in reverse FK order to a clean database, then db:migrate re-applies all seven. (Note for reviewers: this app is multi-database, so bare db:rollback aborts and directs you to the namespaced task.)

Documentation updates

docs/adr/0001-jsonb-semantic-retention.md — the one architectural decision this PR makes, recording that jsonb discards whitespace, key order, and duplicate keys; that array order is preserved; and that a byte-exact text column is deliberately not built. README: status, reviewer-commands note, planned-contents table, and a pointer to docs/adr/.

db/queue_schema.rb is deliberately unchanged — db:prepare re-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_payload was NOT 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 contain null, {}, or [] as an element — each a parsed value with no usable envelope that §7 says to quarantine. NOT NULL turned exactly those into a NotNullViolation aborting the ingest transaction, destroying the malformed event quarantine exists to preserve. Now nullable, and off the presence validation too (presence treats {}/[] as blank). payload_fingerprint stays NOT NULL — the canonical fingerprint of null is as well defined as any other.
  • upsert_stub! reached PostgreSQL unguarded — the same defect already fixed in insert_if_new, missed here. A nil github_id/login/full_name aborted the transaction before the caller could quarantine one envelope. Both stubs now validate! first, backed by real validations (github_id in the Enrichable concern, login on the actor, full_name on the repository). No DB round trip is added, since there is no uniqueness validation.
  • updated_at regressed on a late replay. GREATEST protected last_received_at but not updated_at, and both entity IDENTITY_MERGEs had the same latent bug. All three merges are now monotonic, matching touch_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_at monotonic in round 1 without applying the same rule to the identity assignments 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. 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:

COALESCE(CASE WHEN EXCLUDED.updated_at >= <table>.updated_at
              THEN EXCLUDED.col END,
         <table>.col)

A stale envelope makes the CASE yield NULL so COALESCE keeps the stored value; a fresh envelope omitting an optional field also yields NULL, preserving the original behaviour. >= lets a same-instant write win rather than being discarded. QuarantinedEvent::OCCURRENCE_MERGE needs 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 with got: "old-login"; with it restored it passes.

Known limitations

  • No behaviour reads or writes these tables yet. The primitives are exercised only by specs; PR 5 wires them into the ingest transaction.
  • 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.
  • Counter relationships are unconstrained: the schema permits poll_used > poll_allowance. PR 4's ledger owns reservation arithmetic.
  • event_sources.status and ingestion_runs.status accept any text until PRs 6 and 5 define their vocabularies.
  • The queue database remains schema-empty until PR 8.

batbrainy and others added 2 commits July 29, 2026 17:48
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

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.

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(

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.

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.

Comment thread app/models/quarantined_event.rb Outdated
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

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.

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>
Comment thread app/models/github_actor.rb Outdated
# actor.display_login -> display_login, actor.url -> api_url,
# actor.avatar_url -> avatar_url.
IDENTITY_MERGE = <<~SQL.squish
login = EXCLUDED.login,

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.

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>
@batbrainy
batbrainy merged commit 60955c3 into main Jul 30, 2026
2 checks passed
@batbrainy
batbrainy deleted the pr-3-core-data-model branch July 30, 2026 01:32
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.

PR 3 — Core data model

1 participant