Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
1265 lines
62 KiB
Markdown
1265 lines
62 KiB
Markdown
# Scale Redesign: Infestation Map & Multi-Million Repository Registry
|
||
|
||
| Field | Value |
|
||
|-------|-------|
|
||
| **Document** | Scale redesign - infestation map and registry |
|
||
| **Author** | TBD |
|
||
| **Date** | 2026-07-18 |
|
||
| **Status** | Approved for implementation (rev 3 + product answers) |
|
||
| **App** | Tarakan / TarakanWeb |
|
||
| **Stack** | Phoenix LiveView · Ecto · Postgres · Oban |
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
Tarakan’s infestation / contagion surface (`Infestations`, constellation map, hub-spoke graph) and public registry lists were built for demo scale (~12 listed repos, ~20 patterns). At that size, online multi-join `GROUP BY` aggregation and unbounded `Repo.all()` lists are fine. Growth toward large listed registries and dense public findings makes those paths the binding constraints on page latency, DB CPU, and LiveView memory.
|
||
|
||
This design keeps the product voice - a **public disclosure registry** with a **top-patterns contagion atlas**, not a generic scanner SaaS or a full-mesh graph of every repo - and makes the **infestation atlas/list/show read path** and **paginated registry/SEO surfaces** credible on the **current stack** (single Postgres primary + Phoenix + Oban). The core move is **pre-aggregated infestation rollups updated asynchronously after commits (Oban)**, with **windowed counters that preserve today’s list semantics**, **cursor pagination** for registry/SEO/instance ledgers, **read-shaped indexes**, and **bounded LiveView assigns/streams**.
|
||
|
||
**Scale honesty:** this phase **proves** atlas/list/show and cursor-paginated registry reads; multi-million *listed* is a **target tier** with named prerequisites (pre-ranked window columns, multi-file sitemap, complete write hooks, home collective debouncing). Sharding, multi-region, and full CQRS event stores remain out of scope.
|
||
|
||
This design uses **async projection writes + dual-read** (legacy vs rollup). It is **not** dual-write of business facts (`canonical_findings` / scans remain source of truth).
|
||
|
||
---
|
||
|
||
## Background & Motivation
|
||
|
||
### Product surface today
|
||
|
||
| Concept | Location | Role |
|
||
|---------|----------|------|
|
||
| Pattern key | `FindingMemory.pattern_key/1` | SHA256 of aggressively normalized title; cross-repo class key |
|
||
| Exact fingerprint | `FindingMemory.fingerprint/1` | Per-repo path+lines+title; auto-links occurrences |
|
||
| Canonical findings | `canonical_findings` | Repo-level issue memory + status quorum |
|
||
| Infestations context | `lib/tarakan/infestations.ex` | Online SQL aggregation for map + show |
|
||
| Contagion UI | `lib/tarakan_web/components/infestation_components.ex` | SVG constellation (top N) + hub-spoke graph |
|
||
| LiveViews | `InfestationLive.Index/Show`, home (`RepositoryLive.Index`), `ExploreLive` | Consume infestations on mount / activity refresh |
|
||
|
||
### Current infestation read path (hot)
|
||
|
||
`Infestations.list_infestations/1` joins:
|
||
|
||
`canonical_findings` ⋈ `repositories` ⋈ `scan_findings` ⋈ `scans`
|
||
|
||
filters listed + **full** `scan.visibility == "public"` (not `public_summary`) + non-null `pattern_key` + **`canonical.updated_at >= since` before aggregate**, optional `maybe_status/2` on the pre-aggregate set, then `GROUP BY pattern_key` with **distinct** repo/instance counts and status filters, `HAVING` min repos, `ORDER BY` repo_count / last_seen, `LIMIT` ≤ 100. A second query (`representative_titles/1`) picks display title/severity/sample path per key (today left-joins occurrences **without** visibility filter - fixed in rollup recompute).
|
||
|
||
Call sites:
|
||
|
||
- Home: limit 12, days 30 (`RepositoryLive.Index`)
|
||
- Explore: limit 5 (`ExploreLive`)
|
||
- `/infestations`: limit 50 + filter form (`InfestationLive.Index`; days ∈ {7,30,90,365})
|
||
- Seeds / tests
|
||
|
||
`get_infestation/1` and `list_instances/2` re-run similar joins (instances default limit 100, max 300; return a **list**).
|
||
|
||
### Write path already materializes *repo* metrics
|
||
|
||
`FindingMemory.assimilate_scan/1` upserts canonicals and refreshes per-canonical counters. Public API `Scans.recalculate_repository_metrics/1` (private `recalculate_repository/2`) maintains `repositories.open_findings_count`, `verified_findings_count`, `scan_count`, listing promotion `pending → listed`. **There is no analogous materialization for pattern-level infestation stats.**
|
||
|
||
### Proven ops patterns to reuse
|
||
|
||
- **Oban workers** with uniqueness and batched cursor sweeps: `Tarakan.Sync.RepositorySweep` / `RepositorySweepBatch`.
|
||
- **Nightly cron** via `Oban.Plugins.Cron` in `config/config.exs` (`RepositorySweep` 03:00, `HostedRepositoryGC` weekly).
|
||
- **LiveView streams** on explore wire, moderation queue, admin accounts - not yet infestation instance ledgers.
|
||
- **Bounded search**: `Repositories.search_repositories/2` limits 1-50; contrast unbounded `list_listed_repositories/0` and `Scans.list_indexable_findings/0` used by `SEOController.sitemap/2`.
|
||
- **Post-commit side effects**: `broadcast_review_submitted/1` after `do_submit_scan` Multi success **and** Work task submit Multi success; moderation broadcasts after `moderate_scan` transaction.
|
||
|
||
### Pain points as scale grows
|
||
|
||
| Bottleneck | Symptom |
|
||
|------------|---------|
|
||
| Online infestation aggregation | Multi-second group-bys as canonical/occurrence tables grow |
|
||
| Unbounded registry / findings lists | Sitemap OOM or multi-minute responses |
|
||
| LiveView assigns | Large instance lists; home re-queries infestations + collective payload on every activity |
|
||
| Missing composite indexes | Window + listing filters cannot be index-only |
|
||
| Incomplete projections without hooks | Visibility/listing flips leave stale public contagion until reconcile |
|
||
|
||
---
|
||
|
||
## Goals & Non-Goals
|
||
|
||
### Goals
|
||
|
||
1. **Atlas/list/show read path p95 under ~100ms DB time** once rollups are warm and hooks are live, at **Tier B-C** scale (see Scale tiers) - not a blanket claim that every subsystem is multi-million-ready.
|
||
2. **Preserve production infestation list semantics** (in-window canonical rows before aggregate; listed + full public only; distinct counts) when window columns are fresh, with **documented window-exit lag ≤ ~1h** (hourly recompute + last_seen guard) - not bit-identical live `since` without that maintenance.
|
||
3. **Correct public contagion**: only listed repos + `visibility == "public"` occurrences; quarantine, delist, and visibility takedowns remove contribution after refresh (delist-safe key discovery; no waiting for nightly reconcile for correctness).
|
||
4. **Async projection maintenance** after commits (not on page load), covering every path that changes the public join set.
|
||
5. **Cursor pagination** for listed repositories, infestation instance ledgers, indexable findings, and sitemap generation; multi-file sitemap before multi-million SEO claims.
|
||
6. **Bounded LiveView memory**: top-N atlas; capped graph satellites; streamed/paginated ledger.
|
||
7. **Indexes aligned to read and refresh predicates**.
|
||
8. **Incremental ship plan** with hard cutover gates (hooks → backfill → shadow-compare → flag).
|
||
9. Preserve product framing: **public disclosure registry** and **top patterns atlas**.
|
||
|
||
### Non-Goals
|
||
|
||
- Graph of all repositories or all findings.
|
||
- Cross-region multi-primary, Citus/sharding, or Kafka CQRS.
|
||
- Changing `pattern_key` or fingerprint definitions.
|
||
- Real-time infestation map animation beyond current SVG chrome.
|
||
- Sub-second global consistency under write storms (seconds of lag OK if bounded and measured).
|
||
- Making home LiveView multi-million *activity-rate* ready in this phase (collective payload debouncing is partial; full home scale is follow-up).
|
||
- Replacing Oban.
|
||
|
||
### Scale tiers (honest capacity)
|
||
|
||
| Tier | Listed repos (order) | What this design makes solid | Still aspirational / follow-up |
|
||
|------|----------------------|------------------------------|--------------------------------|
|
||
| **A - near-term** | ~10³-10⁴ | Rollups + hooks + cursors; single-file sitemap streaming | - |
|
||
| **B - growth** | ~10⁴-10⁵ | Pre-ranked window columns; multi-file sitemap (≥ **20k listed** hard gate); stats ETS | Home activity fan-out; contributor count |
|
||
| **C - multi-million listed** | ~10⁶ | Atlas/list/show from `infestation_patterns` indexes only; membership/instance tables for show/refresh | Write amplification on super-hot patterns; full-fleet reconcile duration; home PubSub; possible dirty-keys drain |
|
||
|
||
Capacity sketch (order-of-magnitude, not a promise):
|
||
|
||
| Object | Bound assumption | Storage class |
|
||
|--------|------------------|---------------|
|
||
| Distinct `pattern_key`s | ≪ canonicals (title collapse); plan for 10⁵-10⁶ worst case | `infestation_patterns` ~100-300 B/row → tens-hundreds MB |
|
||
| Memberships (`pattern × listed repo`) | product of multi-repo patterns; dense infestations dominate | 10⁷ rows → few GB with indexes |
|
||
| Instance grain (`pattern × canonical`) | ≤ public canonicals with pattern_key | same order as public canonical subset |
|
||
| Refresh rate | one Oban job per key, unique 30s coalesce | hot keys refresh at most ~2/min each |
|
||
|
||
---
|
||
|
||
## Proposed Design
|
||
|
||
### Architecture (read vs write)
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph WritePath["Post-commit projection"]
|
||
Submit["broadcast_review_submitted Scans+Work"]
|
||
Checks["record_check / refresh_repository_checks"]
|
||
Vis["update_visibility Multi OK"]
|
||
Listing["listing_status any mutator"]
|
||
Sched["Infestations.schedule_refresh_*_after_commit"]
|
||
Worker["Oban: RefreshPattern one key"]
|
||
Decay["Oban: RecomputeWindows hourly"]
|
||
Rollup["infestation_patterns + memberships + instances"]
|
||
Submit --> Sched
|
||
Checks --> Sched
|
||
Vis --> Sched
|
||
Listing --> Sched
|
||
Sched --> Worker
|
||
Worker --> Rollup
|
||
Decay --> Rollup
|
||
end
|
||
|
||
subgraph ReadPath["Read path"]
|
||
LV["LiveViews"]
|
||
API["Infestations.list / get / list_instances"]
|
||
LV --> API
|
||
API --> Rollup
|
||
API -.->|"ledger page"| CF["canonical_findings keyset"]
|
||
end
|
||
```
|
||
|
||
### Design principles
|
||
|
||
1. **Preserve list semantics** via **precomputed fixed-window counters** on `infestation_patterns` (UI windows are only 7/30/90/365), with **explicit window-decay maintenance** so quiet patterns age out without writes.
|
||
2. **Materialize membership + instance grain** for show/graph/refresh - not for fleet-wide list `GROUP BY`.
|
||
3. **Prefer async refresh after commit** so assimilate Multi latency is unchanged.
|
||
4. **One Oban job per `pattern_key`** for uniqueness/coalescing.
|
||
5. **Periodic chained reconcile** ships with the worker - not as a late nice-to-have.
|
||
6. **Atlas ≠ universe**.
|
||
7. **Delist-safe key discovery** always consults existing rollup memberships (not only the live listed join).
|
||
|
||
---
|
||
|
||
### Target semantics (frozen)
|
||
|
||
**Decision: preserve today’s `list_infestations/1` semantics.** Not an intentional product change.
|
||
|
||
Production rules (from `lib/tarakan/infestations.ex`):
|
||
|
||
1. Include a canonical only if:
|
||
- `repository.listing_status == "listed"`
|
||
- exists a linked `scan_findings` row whose parent scan has `visibility == "public"`
|
||
- `pattern_key` present and non-empty
|
||
- **`canonical.updated_at >= since`** (window applied **before** `GROUP BY`)
|
||
2. Optional `status` filter further restricts the **pre-aggregate** set (`maybe_status/2`).
|
||
3. Aggregates use **`count(distinct repository_id)`** and **`count(distinct canonical.id)`** (and status-filtered distincts).
|
||
4. Order: `repo_count DESC`, then `max(updated_at) DESC`; `HAVING repo_count >= min_repos`; limit capped.
|
||
|
||
**Implication for rollups:** membership `last_seen_at` alone is **not** sufficient for list ranking. Online `GROUP BY` on `infestation_pattern_repos` in a dense window is also **not** the multi-million list strategy.
|
||
|
||
**Implementation that matches:**
|
||
|
||
- On each `refresh_pattern!/1`, compute for each standard window `W ∈ {7, 30, 90, 365}` the exact aggregates as if `list_infestations(days: W)` for **status-unfiltered** path, and store them on `infestation_patterns`.
|
||
- `list_infestations` maps `days` to the nearest supported window (UI only offers these four; **exact match** for 7/30/90/365; for other values use smallest W ≥ days and document).
|
||
- Status filter (`status in open|verified|…`): **secondary path** using `infestation_pattern_instances` filtered by `status` and **live** `updated_at >= since` with distinct aggregates (always clock-correct; table is much smaller than full CF⋈occurrence⋈scan join). Main UI does not pass status today; still implement correctly for API parity.
|
||
|
||
#### Window-decay consistency (clock lag is first-class)
|
||
|
||
Precomputed `*_Wd` columns are snapshots of “in-window at `refresh_pattern!` time.” Production evaluates `since = now() - days` **on every read**. Quiet patterns (no assimilate/check/listing event) would otherwise remain on the atlas until nightly reconcile - **up to ~24h** - which is larger than write-lag budget and breaks bit-identical dual-read at time edges.
|
||
|
||
**Chosen strategy: (1) cheap window recompute + (2) read-path last_seen guard.**
|
||
|
||
| Mechanism | What it does | Cadence / cost |
|
||
|-----------|--------------|----------------|
|
||
| **A. `Infestations.RecomputeWindows` Oban job** | For each pattern (cursor), recompute **only** window columns from `infestation_pattern_instances` using `updated_at >= now() - W` - **no** CF⋈scan join; also updates membership `last_seen` aggregates if derived from instances | **Hourly** chained cursor (crontab `0 * * * *`); concurrency 1; budget N keys/run then re-enqueue |
|
||
| **B. Read-path guard** | Default list always adds `last_seen_at_Wd >= since` (or `IS NOT NULL` equivalent) so patterns whose **newest** in-window instance has fully exited cannot rank even if stale columns say otherwise | O(1) index filter on every list |
|
||
| **C. Partial multi-repo decay** | When one of several repos ages out mid-window, rank `repo_count_Wd` can stay high until A runs - **accepted lag ≤ ~1h** (not 24h). Documented; dual-read parity tests use `freeze_time` / compare within lag or force `RecomputeWindows` / `refresh_pattern!` before assert | Hourly A heals partial decay |
|
||
|
||
**Do not claim bit-identical live `since` without A+B.** Goals wording:
|
||
|
||
- **Semantic target:** same as production when window columns are fresh (after refresh or hourly recompute).
|
||
- **Bounded clock lag:** write-path lag p95 < 30s / p99 < 2m; **window-exit / partial-decay lag p95 ≤ 1h** (hourly job), p99 until next reconcile if job backlog.
|
||
- Status-filtered secondary path uses **live** instance `updated_at` and has **no** window-column lag.
|
||
|
||
**Tests:** dual-read parity for status-unfiltered 7/30/90/365 after refresh/recompute; explicit fixture where an instance ages past `since` and disappears from list after `RecomputeWindows` (and is excluded by guard when `last_seen_at_Wd` is null/old). Not “parity of a weakened v1.1.”
|
||
|
||
**Product decision (resolved):** fixed-only patterns **remain on the atlas** when in-window - same as today. No special exclusion of `status == "fixed"`.
|
||
|
||
---
|
||
|
||
### Data model
|
||
|
||
#### Table: `infestation_patterns`
|
||
|
||
One row per `pattern_key` with ≥1 listed+public instance. **When none remain: hard-delete the rollup row** (and related memberships/instances for that key); show page 404s. No soft-delete retention.
|
||
|
||
| Column | Type | Notes |
|
||
|--------|------|-------|
|
||
| `pattern_key` | `string(64)` PK | |
|
||
| `title` | `string` not null | Representative (open-preferring, newest) among **public** occurrences |
|
||
| `severity` | `string` nullable | |
|
||
| `sample_file_path` | `string` nullable | |
|
||
| `sample_occurrence_public_id` | `uuid` nullable | **Must** be a public scan occurrence |
|
||
| `repo_count` | `integer` | All-time listed+public distinct repos |
|
||
| `instance_count` | `integer` | All-time distinct canonicals |
|
||
| `open_count` … `disputed_count` | integers | All-time status tallies (show page / chips) |
|
||
| `first_seen_at` / `last_seen_at` | `utc_datetime_usec` | All-time among counted |
|
||
| **`repo_count_7d` … `repo_count_365d`** | integers | In-window distinct repos (exact semantics) |
|
||
| **`instance_count_7d` … `_365d`** | integers | In-window distinct canonicals |
|
||
| **`open_count_7d` …`** (and verified/fixed/disputed × windows) | integers | In-window status tallies for chips on list cards |
|
||
| **`last_seen_at_7d` … `_365d`** | `utc_datetime_usec` nullable | `max(updated_at)` in window; null if empty |
|
||
| `refreshed_at` | `utc_datetime_usec` not null | Last successful recompute |
|
||
| timestamps | | |
|
||
|
||
No `stats_version` column in v1 (lost updates are acceptable: last writer wins on full recompute from source; concurrent jobs for the same key are coalesced by Oban unique).
|
||
|
||
**Indexes (pre-ranked atlas - O(limit) reads)**
|
||
|
||
```text
|
||
CREATE INDEX infestation_patterns_rank_30d_idx
|
||
ON infestation_patterns (repo_count_30d DESC, last_seen_at_30d DESC NULLS LAST)
|
||
WHERE repo_count_30d >= 2;
|
||
|
||
-- same pattern for 7d, 90d, 365d
|
||
CREATE INDEX infestation_patterns_rank_7d_idx
|
||
ON infestation_patterns (repo_count_7d DESC, last_seen_at_7d DESC NULLS LAST)
|
||
WHERE repo_count_7d >= 2;
|
||
-- … 90d, 365d …
|
||
```
|
||
|
||
#### Table: `infestation_pattern_repos`
|
||
|
||
Per-pattern, per-repository membership (show graph satellites; listing fan-in).
|
||
|
||
| Column | Type | Notes |
|
||
|--------|------|-------|
|
||
| `pattern_key` | `string(64)` | PK part |
|
||
| `repository_id` | `bigint` FK CASCADE | PK part |
|
||
| `instance_count` | integer | Distinct canonicals in repo |
|
||
| `open_count` … `disputed_count` | integers | |
|
||
| `primary_status` | string | open > disputed > verified > fixed |
|
||
| `severity` / `title` | | |
|
||
| `sample_occurrence_public_id` | uuid nullable | Public only |
|
||
| `first_seen_at` / `last_seen_at` | | |
|
||
| timestamps | | |
|
||
|
||
```text
|
||
PRIMARY KEY (pattern_key, repository_id)
|
||
CREATE INDEX infestation_pattern_repos_repo_idx ON infestation_pattern_repos (repository_id);
|
||
CREATE INDEX infestation_pattern_repos_pattern_recent_idx
|
||
ON infestation_pattern_repos (pattern_key, last_seen_at DESC, repository_id DESC);
|
||
```
|
||
|
||
#### Table: `infestation_pattern_instances`
|
||
|
||
Canonical grain for exact window/status recompute, hourly window decay, and status-filtered lists.
|
||
|
||
| Column | Type | Notes |
|
||
|--------|------|-------|
|
||
| `pattern_key` | `string(64)` not null | Denormalized for window indexes |
|
||
| `canonical_finding_id` | `bigint` PK | FK → `canonical_findings(id)` **ON DELETE CASCADE** |
|
||
| `repository_id` | `bigint` not null | FK → `repositories(id)` **ON DELETE CASCADE** (denormalized; matches canonical.repository_id) |
|
||
| `status` | `string` not null | |
|
||
| `severity` | string nullable | |
|
||
| `title` | string | |
|
||
| `file_path` | string nullable | |
|
||
| `sample_occurrence_public_id` | uuid nullable | Newest **public** occurrence for this canonical |
|
||
| `inserted_at` | utc_datetime_usec | canonical.inserted_at |
|
||
| `updated_at` | utc_datetime_usec | canonical.updated_at (drives window) |
|
||
|
||
```text
|
||
PRIMARY KEY (canonical_finding_id)
|
||
FOREIGN KEY (canonical_finding_id) REFERENCES canonical_findings(id) ON DELETE CASCADE
|
||
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE
|
||
CREATE UNIQUE INDEX ON infestation_pattern_instances (pattern_key, canonical_finding_id);
|
||
CREATE INDEX infestation_pattern_instances_window_idx
|
||
ON infestation_pattern_instances (pattern_key, updated_at DESC);
|
||
CREATE INDEX infestation_pattern_instances_status_window_idx
|
||
ON infestation_pattern_instances (status, updated_at DESC, pattern_key);
|
||
CREATE INDEX infestation_pattern_instances_repo_idx
|
||
ON infestation_pattern_instances (repository_id);
|
||
```
|
||
|
||
Refresh rebuilds instance rows for a pattern from the public join, then derives repos + pattern window columns from **this table** (no multi-occurrence inflation).
|
||
|
||
**Delete cleanup:** Hosted or hard-deleted repositories remove `repositories` rows; existing app FKs delete/cascade canonicals; instance + membership projections **CASCADE** clear without enqueue. Empty `infestation_patterns` rows (no remaining instances) are removed by the next `refresh_pattern!` of related keys, hourly `RecomputeWindows` (when instance count becomes 0), or nightly reconcile. No explicit delist enqueue is required for hard DELETE - only for soft listing flips (quarantine/pending) that leave the repository row intact.
|
||
|
||
#### Dirty-keys table (optional, threshold-gated)
|
||
|
||
**Not** in v1 schema. Promote only if metrics show:
|
||
|
||
- Oban `infestations` insert rate > 500/s sustained, **or**
|
||
- unique-job discard rate high while refresh lag > 2m for hot keys
|
||
|
||
Then: `infestation_dirty_keys(pattern_key PK, enqueued_at, reason)` drained by a single worker with `DELETE … RETURNING` batches of 100-500. Until then: **one Oban job per key** only.
|
||
|
||
#### Schema modules
|
||
|
||
```elixir
|
||
# lib/tarakan/infestations/pattern.ex
|
||
# lib/tarakan/infestations/pattern_repo.ex
|
||
# lib/tarakan/infestations/pattern_instance.ex
|
||
```
|
||
|
||
Context API stays on `Tarakan.Infestations`.
|
||
|
||
---
|
||
|
||
### Supporting indexes on source tables
|
||
|
||
```sql
|
||
CREATE INDEX canonical_findings_pattern_repo_idx
|
||
ON canonical_findings (pattern_key, repository_id)
|
||
WHERE pattern_key IS NOT NULL AND pattern_key <> '';
|
||
|
||
CREATE INDEX canonical_findings_pattern_status_idx
|
||
ON canonical_findings (pattern_key, status)
|
||
WHERE pattern_key IS NOT NULL AND pattern_key <> '';
|
||
|
||
CREATE INDEX canonical_findings_pattern_updated_idx
|
||
ON canonical_findings (pattern_key, updated_at DESC)
|
||
WHERE pattern_key IS NOT NULL AND pattern_key <> '';
|
||
|
||
CREATE INDEX repositories_listed_id_idx
|
||
ON repositories (id)
|
||
WHERE listing_status = 'listed';
|
||
|
||
CREATE INDEX scans_public_repo_idx
|
||
ON scans (repository_id, id)
|
||
WHERE visibility = 'public';
|
||
```
|
||
|
||
Partial predicates must match Ecto (`is_nil` / `!= ""` → SQL `IS NOT NULL AND <> ''`).
|
||
|
||
#### Zero-downtime index / migration ops
|
||
|
||
Deploy path today: Docker image via `scripts/deploy/deploy.sh` → `docker compose up`; app container migrates then serves (`deploy/docker/compose.yml`: migrate then server via release).
|
||
|
||
**Pool sizing (authoritative for deploy):**
|
||
|
||
| Source | Default `POOL_SIZE` |
|
||
|--------|---------------------|
|
||
| `config/runtime.exs` | `10` if env unset |
|
||
| **`deploy/docker/compose.yml` production** | **`POOL_SIZE:-5`** (“minimum 2 GB deployment”) - **this is the deploy default** |
|
||
|
||
Size Oban against **compose default 5**, not runtime’s 10:
|
||
|
||
| Queue | Concurrency | Notes |
|
||
|-------|-------------|-------|
|
||
| `sync` | 5 | Existing |
|
||
| `mirror` | 3 | Existing - already can saturate a pool of 5 under sweep+mirror; known pressure |
|
||
| `infestations` | **1** (v1) | Raise to 2 only if `POOL_SIZE ≥ 10`; to 3 only if `POOL_SIZE ≥ 15` |
|
||
| web/LiveView | needs residual connections | Prefer `POOL_SIZE ≥ 15` when infestations workers are enabled in prod |
|
||
|
||
Document in ops: enabling infestations workers ⇒ set `POOL_SIZE=15` (or higher) in `.env`. Design claim is **not** “sync+mirror+infestations ≤ 10 by default.”
|
||
|
||
| Step | Guidance |
|
||
|------|----------|
|
||
| Small tables (empty rollups) | Normal `mix ecto.migrate` / `Tarakan.Release.migrate` in deploy is fine |
|
||
| Large concurrent indexes on hot `canonical_findings` | Prefer **separate migration** with `disable_ddl_transaction: true` and `CREATE INDEX CONCURRENTLY`; run in a maintenance window if table is already huge; app **can** deploy with rollup tables present and indexes still building - refresh is slower but correct |
|
||
| Lock monitoring | Watch `pg_stat_activity` / `pg_locks` during concurrent builds; avoid stacking multiple concurrent index builds |
|
||
| Incomplete indexes | Feature flag stays off until backfill + indexes valid (`pg_index.indisvalid`) |
|
||
| Pool vs Oban | Start `infestations: 1`; raise only with documented `POOL_SIZE` |
|
||
|
||
---
|
||
|
||
### Write-path hooks
|
||
|
||
#### Mutate-path matrix (public join set)
|
||
|
||
Any path that can attach/detach listed+public contribution **or** change canonical status contributing to rollups **must** schedule refresh after successful **commit**.
|
||
|
||
| Event | Code location | Keys | Reason |
|
||
|-------|---------------|------|--------|
|
||
| Review submit (Scans) | `Scans.do_submit_scan/4` Multi **success** (with `broadcast_review_submitted/1`) | Scan-scoped pattern_keys; **plus** repository-scoped **only if** `listing_status` changed in Multi | `:assimilate` / `:listing_change` |
|
||
| Review submit (Work / jobs) | `Tarakan.Work` contribution submit Multi success that calls `Scans.stage_review_insert/1` then `Scans.broadcast_review_submitted/1` (~work.ex submit path) - **not** only `do_submit_scan` | Same as Scans submit | `:assimilate` / `:listing_change` |
|
||
| Other assimilate | Seeds or any post-commit caller of `FindingMemory.assimilate_scan/1` outside a Multi | Returned pattern_keys | `:assimilate` |
|
||
| Finding check | `FindingMemory.record_check/4` **after** transaction success (status/counts changed) | That canonical’s `pattern_key` | `:status` |
|
||
| Report-level check | Confirmation Multi in `Scans` that runs `assimilate_report_check/3` **inside** Multi (`:finding_checks` step) - schedule on Multi **success branch only**, never inside `assimilate_report_check` | Touched keys from return value or scan | `:status` |
|
||
| Authority revalidate | `Scans.revalidate_repository_authority/1` → `FindingMemory.refresh_repository_checks/1` (membership / platform role changes via `Repositories.notify_membership_authorization_change`) | Distinct pattern_keys of canonicals in repo whose status/counts may have flipped (or repository-scoped refresh) | `:status` |
|
||
| Visibility change | `Scans.update_visibility/4` → `moderate_scan/4` Multi OK when visibility changed | Scan-scoped keys | `:visibility` |
|
||
| Listing via recalculate | Multi success when private `recalculate_repository/2` changed `listing_status` (`do_submit_scan`, `moderate_scan`) | **Delist-safe** repository key discovery (below) | `:listing_change` |
|
||
| Explicit listing | `Repositories.update_listing_status/3` after successful update | Delist-safe repository keys | `:listing_change` |
|
||
| Work publish promotion | `Work.promote_pending_repository!/2` (from `publish_task/3`) pending→listed - **outside** `update_listing_status/3` and outside `recalculate_repository/2` | Delist-safe repository keys | `:listing_change` |
|
||
| Hosted / moderation listing | Any other writer of `listing_status` | Delist-safe repository keys | `:listing_change` |
|
||
| Hourly window decay | `Infestations.RecomputeWindows` | Cursor over patterns | `:window_decay` |
|
||
| Reconcile / backfill | Workers | Key set | `:reconcile` / `:backfill` |
|
||
|
||
**Centralization preference:** implement scan-scoped scheduling once next to every post-commit `broadcast_review_submitted/1` (Scans + Work), so new submit entrypoints cannot forget assimilate hooks. Listing: one helper `schedule_refresh_for_repository_after_commit/2` called from **every** `listing_status` mutator.
|
||
|
||
**Not sufficient alone:** tail of `assimilate_scan/1` or body of `assimilate_report_check/3` (both may run inside Multi). Mid-transaction Oban insert is forbidden.
|
||
|
||
#### Delist-safe repository key discovery
|
||
|
||
After `listed → quarantined|pending` (or reverse), key discovery **must not** require `listing_status == "listed"` on the live join - that returns **zero keys** post-delist and leaves stale memberships until reconcile.
|
||
|
||
```elixir
|
||
@doc """
|
||
Union of (1) keys already projected for this repo and (2) keys still present
|
||
on public occurrences ignoring listing_status. Recompute applies listed+public.
|
||
"""
|
||
defp pattern_keys_for_repository(repository_id) do
|
||
from_rollup =
|
||
from(m in PatternRepo,
|
||
where: m.repository_id == ^repository_id,
|
||
select: m.pattern_key,
|
||
distinct: true
|
||
)
|
||
|
||
# Public occurrences on this repo - do NOT filter repositories.listing_status
|
||
from_source =
|
||
from(c in CanonicalFinding,
|
||
join: f in Finding, on: f.canonical_finding_id == c.id,
|
||
join: s in Scan, on: s.id == f.scan_id,
|
||
where: c.repository_id == ^repository_id and s.visibility == "public",
|
||
where: not is_nil(c.pattern_key) and c.pattern_key != "",
|
||
select: c.pattern_key,
|
||
distinct: true
|
||
)
|
||
|
||
(Repo.all(from_rollup) ++ Repo.all(from_source))
|
||
|> Enum.uniq()
|
||
|> Enum.reject(&(&1 in [nil, ""]))
|
||
end
|
||
```
|
||
|
||
Recompute still applies `listing_status == "listed"` + public, so delisted repos **drop** from instances/memberships.
|
||
|
||
**Required test:** quarantine a listed multi-pattern repo → within Oban uniqueness window (+ forced drain in test), `infestation_pattern_repos` rows for that `repository_id` are gone and affected patterns’ window/all-time counts decrease - **without** waiting for nightly reconcile.
|
||
|
||
#### Post-commit API
|
||
|
||
```elixir
|
||
@doc """
|
||
Enqueue per-key refresh jobs. Safe only after the outer transaction committed.
|
||
Call from Multi success branches and other post-commit hooks - not from inside Multi.run.
|
||
"""
|
||
def schedule_refresh_after_commit(pattern_keys, opts \\ []) when is_list(pattern_keys)
|
||
|
||
def schedule_refresh_for_repository_after_commit(repository_id, opts \\ [])
|
||
|
||
def schedule_refresh_for_scan_after_commit(%Scan{} = scan, opts \\ [])
|
||
```
|
||
|
||
Scan helper: distinct `pattern_key` from canonicals linked to the scan’s findings (listing filter irrelevant).
|
||
|
||
```elixir
|
||
# Scans.do_submit_scan success branch (illustrative) - listing-gated repo fan-out
|
||
{:ok, %{scan: scan, locked_repository: before_repo, repository: after_repo}} ->
|
||
scan = preload_record(scan)
|
||
broadcast_review_submitted(scan)
|
||
_ = Work.maybe_open_agent_verification_job(scan)
|
||
_ = Infestations.schedule_refresh_for_scan_after_commit(scan, reason: :assimilate)
|
||
|
||
if before_repo.listing_status != after_repo.listing_status do
|
||
_ =
|
||
Infestations.schedule_refresh_for_repository_after_commit(scan.repository_id,
|
||
reason: :listing_change
|
||
)
|
||
end
|
||
|
||
{:ok, scan}
|
||
|
||
# Work submit success (same assimilate hook centralization)
|
||
{:ok, %{review: review}} ->
|
||
Scans.broadcast_review_submitted(review)
|
||
_ = Infestations.schedule_refresh_for_scan_after_commit(review, reason: :assimilate)
|
||
# listing: only if promote_pending_repository! ran and flipped status
|
||
...
|
||
```
|
||
|
||
**Scan-scoped** enqueue is sufficient when listing is unchanged (avoids O(patterns_in_repo) fan-out on every report). **Repository-scoped** only on listing flips (or authority revalidate when cheaper than enumerating changed keys).
|
||
|
||
`FindingMemory.assimilate_scan/1` / `assimilate_report_check/3` may **return** pattern_keys to callers; they must **not** Oban.insert when `Repo.in_transaction?()`.
|
||
|
||
```elixir
|
||
def schedule_refresh_after_commit(pattern_keys, opts) do
|
||
if Repo.in_transaction?() do
|
||
raise "Infestations.schedule_refresh_after_commit/2 called inside a transaction"
|
||
end
|
||
Enum.each(Enum.uniq(pattern_keys), &enqueue_one/1)
|
||
end
|
||
```
|
||
|
||
#### Oban worker: `Tarakan.Infestations.RefreshPattern` (**one key**)
|
||
|
||
```elixir
|
||
use Oban.Worker,
|
||
queue: :infestations,
|
||
max_attempts: 5,
|
||
unique: [
|
||
period: 30,
|
||
fields: [:args, :worker],
|
||
keys: [:pattern_key],
|
||
states: [:available, :scheduled, :executing, :retryable]
|
||
]
|
||
|
||
# args: %{"pattern_key" => <<64 hex>>, "reason" => "assimilate"}
|
||
```
|
||
|
||
**Rule:** never multi-key args. Listing fan-out enqueues **N unique single-key jobs**.
|
||
|
||
**Monorepo / thousands of keys:** cap enqueue per event with chunked self-scheduling:
|
||
|
||
```elixir
|
||
@max_inline_enqueue 200
|
||
|
||
def schedule_refresh_for_repository_after_commit(repository_id, opts) do
|
||
keys = pattern_keys_for_repository(repository_id) # delist-safe union - see above
|
||
{now, rest} = Enum.split(keys, @max_inline_enqueue)
|
||
Enum.each(now, &enqueue_one/1)
|
||
if rest != [] do
|
||
%{repository_id: repository_id, offset: @max_inline_enqueue}
|
||
|> Tarakan.Infestations.EnqueueRepoPatterns.new()
|
||
|> Oban.insert()
|
||
end
|
||
end
|
||
```
|
||
|
||
Max enqueue rate: unique coalescing (same key collapses for 30s) + `@max_inline_enqueue` per listing event + chain jobs. Assimilate without listing change enqueues only scan keys (usually few).
|
||
|
||
#### Queue / cron config (merged with existing)
|
||
|
||
```elixir
|
||
config :tarakan, Oban,
|
||
engine: Oban.Engines.Basic,
|
||
repo: Tarakan.Repo,
|
||
queues: [
|
||
sync: 5,
|
||
mirror: 3,
|
||
# Deploy default POOL_SIZE is 5 (compose). Start at 1; see pool sizing table.
|
||
infestations: 1
|
||
],
|
||
plugins: [
|
||
{Oban.Plugins.Pruner, max_age: 7 * 24 * 60 * 60},
|
||
{Oban.Plugins.Cron,
|
||
crontab: [
|
||
{"0 3 * * *", Tarakan.Sync.RepositorySweep},
|
||
{"30 3 * * *", Tarakan.Infestations.Reconcile},
|
||
{"0 4 * * 0", Tarakan.Sync.HostedRepositoryGC},
|
||
# Window-exit / partial multi-repo decay from infestation_pattern_instances only
|
||
{"0 * * * *", Tarakan.Infestations.RecomputeWindows}
|
||
]}
|
||
]
|
||
```
|
||
|
||
**One** full reconcile cadence in v1 (nightly chained) **plus** hourly cheap window recompute. No `*/15` full reconcile.
|
||
|
||
#### Recompute algorithm (`Infestations.refresh_pattern!/1`)
|
||
|
||
For a single `pattern_key`:
|
||
|
||
1. **Distinct-canonical source query** (prevent multi-occurrence inflation):
|
||
|
||
```sql
|
||
-- One row per canonical that has ≥1 public occurrence on a listed repo
|
||
WITH public_canonicals AS (
|
||
SELECT DISTINCT ON (c.id)
|
||
c.id AS canonical_finding_id,
|
||
c.repository_id,
|
||
c.status,
|
||
c.severity,
|
||
c.title,
|
||
c.file_path,
|
||
c.inserted_at,
|
||
c.updated_at,
|
||
f.public_id AS sample_occurrence_public_id
|
||
FROM canonical_findings c
|
||
INNER JOIN repositories r ON r.id = c.repository_id
|
||
INNER JOIN scan_findings f ON f.canonical_finding_id = c.id
|
||
INNER JOIN scans s ON s.id = f.scan_id
|
||
WHERE c.pattern_key = $1
|
||
AND c.pattern_key IS NOT NULL AND c.pattern_key <> ''
|
||
AND r.listing_status = 'listed'
|
||
AND s.visibility = 'public'
|
||
ORDER BY c.id, s.inserted_at DESC, f.id DESC -- newest public occurrence as sample
|
||
)
|
||
SELECT * FROM public_canonicals;
|
||
```
|
||
|
||
Aggregate expressions (pattern-level all-time):
|
||
|
||
```sql
|
||
COUNT(DISTINCT canonical_finding_id) AS instance_count
|
||
COUNT(DISTINCT repository_id) AS repo_count
|
||
COUNT(DISTINCT canonical_finding_id) FILTER (WHERE status = 'open') AS open_count
|
||
-- similarly verified / fixed / disputed
|
||
MIN(inserted_at), MAX(updated_at)
|
||
```
|
||
|
||
Window `W` days:
|
||
|
||
```sql
|
||
-- same filters plus updated_at >= now() - W days
|
||
```
|
||
|
||
2. **Replace instance rows** for the pattern:
|
||
|
||
```sql
|
||
DELETE FROM infestation_pattern_instances WHERE pattern_key = $1;
|
||
INSERT INTO infestation_pattern_instances (...) SELECT ... FROM public_canonicals;
|
||
```
|
||
|
||
(Use a single transaction per pattern. For huge patterns, temp table + swap is OK.)
|
||
|
||
3. **Upsert memberships** from instances (`GROUP BY repository_id` with distinct counts). Stale membership delete via anti-join, **not** giant `NOT IN` lists:
|
||
|
||
```sql
|
||
DELETE FROM infestation_pattern_repos e
|
||
WHERE e.pattern_key = $1
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM infestation_pattern_instances i
|
||
WHERE i.pattern_key = e.pattern_key AND i.repository_id = e.repository_id
|
||
);
|
||
```
|
||
|
||
4. **Upsert `infestation_patterns`** including all window columns from instance `updated_at` filters. Representative title/severity: open-preferring order among instances; **sample_occurrence_public_id only from public join** (step 1).
|
||
|
||
5. If no instances remain → **`DELETE`** pattern + repos + instances for key (**product: hard delete; `get_infestation` → nil → show 404**. No soft-delete / SEO zombie rows).
|
||
|
||
6. Telemetry `[:tarakan, :infestations, :refresh]`.
|
||
|
||
**Property tests:** multi-occurrence canonical counts once for instance_count and status tallies; restricted-only occurrences never set `sample_occurrence_public_id`.
|
||
|
||
#### Nightly reconcile: `Tarakan.Infestations.Reconcile`
|
||
|
||
Ships in **PR 2** (worker + perform), cron enabled in **PR 4** cutover checklist (can insert cron earlier disabled via empty queue). Cursor over distinct `pattern_key` from `canonical_findings`, enqueue `RefreshPattern` jobs (or call `refresh_pattern!` inline with limit per run). Chain with `last_pattern_key` in args like `RepositorySweep`. Also delete orphan rollup rows whose key no longer appears in public join.
|
||
|
||
---
|
||
|
||
### Read APIs
|
||
|
||
#### `Infestations.list_infestations/1` (rollup path)
|
||
|
||
```elixir
|
||
def list_infestations(opts \\ []) do
|
||
if read_from_rollup?() do
|
||
list_infestations_from_rollup(opts)
|
||
else
|
||
list_infestations_legacy(opts)
|
||
end
|
||
end
|
||
|
||
defp list_infestations_from_rollup(opts) do
|
||
min_repos = ...
|
||
limit = ...
|
||
days = ...
|
||
status = Keyword.get(opts, :status)
|
||
window = window_bucket(days) # 7 | 30 | 90 | 365
|
||
since = DateTime.add(DateTime.utc_now(), -days, :day)
|
||
|
||
case status do
|
||
s when s in [nil, "all"] ->
|
||
# O(limit) via rank index + last_seen guard (full window-exit without waiting for job)
|
||
from(p in Pattern,
|
||
where: field(p, ^repo_count_field(window)) >= ^min_repos,
|
||
where: field(p, ^last_seen_field(window)) >= ^since,
|
||
order_by: [
|
||
desc: field(p, ^repo_count_field(window)),
|
||
desc: field(p, ^last_seen_field(window))
|
||
],
|
||
limit: ^limit
|
||
)
|
||
|> Repo.all()
|
||
|> Enum.map(&to_infestation_map(&1, window))
|
||
|
||
s when s in ~w(open verified disputed fixed) ->
|
||
# Secondary: live instance grain (clock-correct; no window-column lag)
|
||
list_infestations_status_window(s, since, min_repos, limit)
|
||
end
|
||
end
|
||
```
|
||
|
||
`to_infestation_map/2` exposes the same keys as today (`repo_count`, `instance_count`, status counts, `last_seen_at`, …) populated from the **windowed** columns so LiveViews need no change. Partial multi-repo decay lag is healed by hourly `RecomputeWindows` (see Target semantics).
|
||
|
||
#### `Infestations.get_infestation/1`
|
||
|
||
Rollup PK lookup; map uses **all-time** counts for show hero (today’s `get_infestation` is not window-scoped). `nil` if missing.
|
||
|
||
#### Instances API (compatibility) - two limit tiers
|
||
|
||
Production today: `list_instances/2` default **100**, max **300**; swarm uses `limit: 200`.
|
||
|
||
| API | Default limit | Max limit | Callers |
|
||
|-----|---------------|-----------|---------|
|
||
| `list_instances/2` (compat **list**) | 100 | **300** (unchanged production cap) | `swarm_check_jobs/2` (`limit: 200`), tests, any list consumer |
|
||
| `list_instances_page/2` (UI cursor) | 50 | **100** | `InfestationLive.Show` ledger only |
|
||
| `list_pattern_repos_page/2` | 64 | 100 | Contagion graph satellites |
|
||
|
||
```elixir
|
||
@list_instances_max 300
|
||
@page_max 100
|
||
|
||
@doc "Backward-compatible list. Max 300 (production parity). Prefer list_instances_page/2 for UIs."
|
||
def list_instances(pattern_key, opts \\ []) do
|
||
limit = opts |> Keyword.get(:limit, 100) |> max(1) |> min(@list_instances_max)
|
||
# Direct keyset query (or page helper with elevated max for this function only)
|
||
fetch_instances(pattern_key, limit: limit, cursor: nil).entries
|
||
end
|
||
|
||
@doc """
|
||
Cursor page for UI ledger. Max 100 (default 50). Does not raise max to 300 -
|
||
swarm must use list_instances/2.
|
||
"""
|
||
def list_instances_page(pattern_key, opts \\ []) do
|
||
limit = opts |> Keyword.get(:limit, 50) |> max(1) |> min(@page_max)
|
||
fetch_instances(pattern_key, limit: limit, cursor: opts[:cursor])
|
||
end
|
||
|
||
def list_pattern_repos_page(pattern_key, opts \\ [])
|
||
```
|
||
|
||
**Do not** implement `list_instances` as `list_instances_page(...)` with page max 100 - that would clamp swarm’s 200 incorrectly.
|
||
|
||
**Show LiveView contract (split sources):**
|
||
|
||
| UI region | Source | Cap |
|
||
|-----------|--------|-----|
|
||
| Hero counts | `get_infestation` | - |
|
||
| Contagion graph | `list_pattern_repos_page` limit **64** | show “+N more in ledger” when `repo_count > length(entries)` |
|
||
| Instance ledger | `list_instances_page` stream + load more | page 50 |
|
||
|
||
Do **not** feed one capped list into both graph and ledger (avoids starving the ledger).
|
||
|
||
**Cursor** keyset: `(updated_at DESC, id DESC)` on canonical/instance grain.
|
||
|
||
#### `swarm_check_jobs/2`
|
||
|
||
Unchanged budget (`@max_swarm_jobs 25`); uses `list_instances(pattern_key, limit: 200)` (**list** API, max 300), filter open, take 25.
|
||
|
||
---
|
||
|
||
### Registry list & SEO pagination
|
||
|
||
#### Named page/stream APIs (no conflicting arities)
|
||
|
||
```elixir
|
||
@doc "Cursor page of listed repositories (id ascending)."
|
||
def list_listed_repositories_page(opts \\ []) do
|
||
limit = opts |> Keyword.get(:limit, 1_000) |> max(1) |> min(5_000)
|
||
after_id = Keyword.get(opts, :after_id, 0)
|
||
# ... where listing_status == "listed" and id > after_id ...
|
||
%{repositories: repos, next_after_id: next_or_nil}
|
||
end
|
||
|
||
@doc "Lazy stream of listed repositories for SEO/export. Never materializes full list."
|
||
def stream_listed_repositories(opts \\ []) do
|
||
Stream.resource(
|
||
fn -> 0 end,
|
||
fn
|
||
nil -> {:halt, nil}
|
||
after_id ->
|
||
%{repositories: repos, next_after_id: next} =
|
||
list_listed_repositories_page(Keyword.merge(opts, after_id: after_id))
|
||
{repos, next}
|
||
end,
|
||
fn _ -> :ok end
|
||
)
|
||
end
|
||
```
|
||
|
||
**Remove** production full-dump zero-arity `list_listed_repositories/0`. Grep and migrate callers (`SEOController`); tests use page/stream helpers.
|
||
|
||
Same treatment for findings:
|
||
|
||
```elixir
|
||
def list_indexable_findings_page(opts \\ [])
|
||
def stream_indexable_findings(opts \\ [])
|
||
```
|
||
|
||
#### Sitemap
|
||
|
||
**Phase A (PR 7):** stream repos + findings + jobs via cursors; never `Repo.all()` of the fleet; still one XML file with `@max_urls 50_000`.
|
||
|
||
**Phase B (PR 7b - hard gate for Tier B+ SEO):** sitemap index when **listed count ≥ 20_000** OR projected URL count would exceed 50k:
|
||
|
||
```text
|
||
/sitemap.xml → sitemapindex
|
||
/sitemap/hubs.xml
|
||
/sitemap/findings/:page.xml
|
||
/sitemap/jobs/:page.xml
|
||
/sitemap/repos/:page.xml → 10k URLs each
|
||
```
|
||
|
||
Open Question #3 is **answered for engineering gate: 20k listed**. Product may lower later.
|
||
|
||
#### `registry_stats/0`
|
||
|
||
ETS TTL **30-60s** first (PR 8). Counter row later if needed.
|
||
|
||
#### Home LiveView refresh discipline
|
||
|
||
**In scope this design:**
|
||
|
||
- Debounce **infestations** reload (15-30s) on `{:activity, _}`.
|
||
- ETS TTL for `registry_stats`.
|
||
|
||
**Explicitly partial / follow-up (not multi-million-activity-ready):**
|
||
|
||
- `refresh_collective/0` still reloads `public_contributor_count/0`, open tasks, scan queue on many events.
|
||
- Acceptance: home remains correct at Tier A-B traffic; high-frequency activity fan-out is a **known remaining bottleneck**, not solved solely by infestation rollups.
|
||
|
||
```elixir
|
||
def handle_info({:activity, _entry}, socket) do
|
||
socket =
|
||
socket
|
||
|> maybe_refresh_collective_light() # tasks/queue with optional debounce
|
||
|> maybe_refresh_infestations(interval_ms: 15_000)
|
||
|
||
{:noreply, socket}
|
||
end
|
||
```
|
||
|
||
---
|
||
|
||
### UI changes
|
||
|
||
| Surface | Change |
|
||
|---------|--------|
|
||
| Constellation | Unchanged shape; top-N from rollup |
|
||
| `InfestationLive.Index` | Rollup list; filters unchanged |
|
||
| `InfestationLive.Show` | Split graph (`list_pattern_repos_page`) vs ledger (`list_instances_page` stream); **required** “+N more in ledger” when `repo_count > satellites_shown` |
|
||
| Home / Explore | Faster atlas; debounce infestations |
|
||
| SEO | Stream; multi-file at 20k listed |
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant U as Browser
|
||
participant LV as InfestationLive.Show
|
||
participant E as Infestations
|
||
participant PG as Postgres
|
||
|
||
U->>LV: mount
|
||
LV->>E: get_infestation(key)
|
||
LV->>E: list_pattern_repos_page(key, limit: 64)
|
||
LV->>E: list_instances_page(key, limit: 50)
|
||
Note over LV: Render +N more if repo_count > 64
|
||
U->>LV: load_more_instances
|
||
LV->>E: list_instances_page(key, cursor: …)
|
||
```
|
||
|
||
---
|
||
|
||
### Migration & backfill
|
||
|
||
#### Expand
|
||
|
||
1. Create three rollup tables.
|
||
2. Supporting indexes (concurrent for large source tables).
|
||
3. App boots with empty rollups; `read_from_rollup: false`.
|
||
|
||
#### Feature flag (runtime-readable)
|
||
|
||
```elixir
|
||
config :tarakan, :infestations,
|
||
read_from_rollup: false,
|
||
refresh_async: true
|
||
```
|
||
|
||
```elixir
|
||
defp read_from_rollup? do
|
||
conf = Application.get_env(:tarakan, :infestations, [])
|
||
Keyword.get(conf, :read_from_rollup, false) == true
|
||
and Keyword.get(conf, :refresh_async, true) == true
|
||
and rollup_ready?() # optional: coverage metric / explicit :rollup_ready
|
||
end
|
||
```
|
||
|
||
**Ops rollback (single-node today):**
|
||
|
||
1. Preferred: `Application.put_env(:tarakan, :infestations, Keyword.put(conf, :read_from_rollup, false))` via remote console - takes effect on next `list_infestations` call (each call reads env).
|
||
2. Or redeploy/restart with config flag false.
|
||
3. Set Oban queue `infestations: 0` to shed refresh load.
|
||
|
||
No multi-node percentage rollout language: app is single-node (`docker-compose` / one Phoenix). Sticky LB multi-node is out of scope.
|
||
|
||
#### Cutover gates (hard)
|
||
|
||
`read_from_rollup: true` only when **all** hold:
|
||
|
||
1. PR 3 hooks deployed (`refresh_async` paths live).
|
||
2. Backfill completed (coverage: count of rollup patterns with `refreshed_at` recent vs distinct source keys ≥ threshold, e.g. 99%).
|
||
3. Shadow-compare running with **both** legacy and rollup under live hooks (not frozen backfill-only).
|
||
4. Reconcile worker available (cron or manual) + lag alert wired.
|
||
5. Rank indexes valid.
|
||
|
||
Refuse silent enable: document checklist in PR 4/6; optional runtime guard logs error if flag true but `refresh_async` false.
|
||
|
||
#### Backfill
|
||
|
||
`Tarakan.Infestations.Backfill` - cursor distinct keys, enqueue `RefreshPattern` or inline refresh. Progress logs every 1k keys.
|
||
|
||
#### Rollback
|
||
|
||
Flag off → legacy reads; tables retained (expand/contract).
|
||
|
||
---
|
||
|
||
### Consistency model
|
||
|
||
| Scenario | Behavior |
|
||
|----------|----------|
|
||
| Report submitted | Canonical immediate; atlas after Oban (write lag p95 < 30s, p99 < 2m) |
|
||
| Visibility restricted | Refresh removes contribution; lag until job runs |
|
||
| Check status flip | Windowed counts update on refresh |
|
||
| Repo quarantined / delisted | Delist-safe key discovery from rollup ∪ public-ignoring-listing; refresh drops memberships |
|
||
| Hard repo DELETE | CASCADE clears instances/memberships; reconcile/window job drops empty patterns |
|
||
| Quiet pattern ages out of window | Hourly `RecomputeWindows` + list `last_seen_at_Wd >= since` guard; partial multi-repo decay ≤ ~1h |
|
||
| Missed hook | Nightly reconcile heals |
|
||
| Concurrent refresh same key | Oban unique coalesces; last full recompute wins |
|
||
|
||
---
|
||
|
||
## API / Interface Changes
|
||
|
||
```elixir
|
||
# Projection
|
||
Infestations.schedule_refresh_after_commit(pattern_keys, opts \\ [])
|
||
Infestations.schedule_refresh_for_scan_after_commit(scan, opts \\ [])
|
||
Infestations.schedule_refresh_for_repository_after_commit(repository_id, opts \\ [])
|
||
Infestations.refresh_pattern!(pattern_key) :: :ok
|
||
Infestations.recompute_windows!(pattern_key) :: :ok # instance-table only
|
||
|
||
# Reads
|
||
Infestations.list_infestations(opts) :: [map()]
|
||
Infestations.get_infestation(pattern_key) :: map() | nil
|
||
Infestations.list_instances(pattern_key, opts) :: [map()] # compat list, max 300
|
||
Infestations.list_instances_page(pattern_key, opts) :: %{entries: [map()], next_cursor: term() | nil} # UI max 100
|
||
Infestations.list_pattern_repos_page(pattern_key, opts) :: %{entries: [map()], next_cursor: term() | nil}
|
||
|
||
# Registry
|
||
Repositories.list_listed_repositories_page(opts) :: %{repositories: [...], next_after_id: integer() | nil}
|
||
Repositories.stream_listed_repositories(opts) :: Enumerable.t()
|
||
Scans.list_indexable_findings_page(opts)
|
||
Scans.stream_indexable_findings(opts)
|
||
|
||
# Workers
|
||
Tarakan.Infestations.RefreshPattern # one key, full recompute from CF⋈public
|
||
Tarakan.Infestations.RecomputeWindows # hourly cheap window columns from instances
|
||
Tarakan.Infestations.EnqueueRepoPatterns # listing fan-out continuation
|
||
Tarakan.Infestations.Reconcile
|
||
Tarakan.Infestations.Backfill
|
||
```
|
||
|
||
Infestation map field names for components/tests remain stable.
|
||
|
||
### HTTP
|
||
|
||
LiveView routes unchanged. Sitemap gains index/child routes in Phase B.
|
||
|
||
---
|
||
|
||
## Data Model Changes
|
||
|
||
```mermaid
|
||
erDiagram
|
||
infestation_patterns ||--o{ infestation_pattern_repos : members
|
||
infestation_patterns ||--o{ infestation_pattern_instances : instances
|
||
repositories ||--o{ infestation_pattern_repos : infected
|
||
canonical_findings ||--o| infestation_pattern_instances : projects
|
||
```
|
||
|
||
Migration strategy: expand → hooks → backfill → shadow-compare → flag → contract legacy.
|
||
|
||
---
|
||
|
||
## Alternatives Considered
|
||
|
||
### A. Postgres materialized views + concurrent refresh
|
||
|
||
Reject as primary: hard to refresh single keys; full refresh cost scales poorly.
|
||
|
||
### B. Online aggregation + heavier indexes only
|
||
|
||
Reject for list path; keep source indexes for refresh/detail.
|
||
|
||
### C. Kafka / full CQRS
|
||
|
||
Out of scope for this phase.
|
||
|
||
### D. HyperLogLog / sketches
|
||
|
||
Reject: disclosure registry needs exact public counts.
|
||
|
||
### E. Synchronous refresh inside assimilate Multi
|
||
|
||
Reject: write latency and transactional Oban hazards.
|
||
|
||
### F. Periodic global top-N snapshot (home/explore only)
|
||
|
||
- **Idea:** one Oban job every 30-60s writes a small `infestation_atlas_snapshot` (top 100 rows for default filters); home/explore read only that blob. Detail pages use online or per-key rollups.
|
||
- **Pros:** tiny read path; simple; good for homepage chrome early.
|
||
- **Cons:** does not help `/infestations` filters (min_repos/days), show pages, or status parity; second projection to maintain; still need per-key refresh for correctness elsewhere.
|
||
- **Decision:** **Reject as primary architecture.** Optional **later** optimization *on top of* `infestation_patterns` rank indexes if home still hot - snapshot would be a cache of the rollup query, not a substitute for windowed columns + hooks.
|
||
|
||
---
|
||
|
||
## Security & Privacy Considerations
|
||
|
||
| Threat | Mitigation |
|
||
|--------|------------|
|
||
| Rollup leaks non-public / quarantined | Refresh join: listed + `visibility = 'public'` only; hooks on visibility **and** listing |
|
||
| Sample UUID points at restricted occurrence | Sample selected only from public join; test coverage |
|
||
| Pattern key enumeration | Public by design for infestations |
|
||
| Sitemap amplification | Cursor stream + 50k cap + multi-file at 20k listed |
|
||
| Mid-transaction job insert | Post-commit API; raise if `Repo.in_transaction?` |
|
||
| Swarm abuse | Existing moderator + budgets |
|
||
|
||
---
|
||
|
||
## Observability
|
||
|
||
| Event | Notes |
|
||
|-------|-------|
|
||
| `[:tarakan, :infestations, :list]` | `source: rollup\|legacy`, window, duration |
|
||
| `[:tarakan, :infestations, :refresh]` | duration, repo_count, reason |
|
||
| `[:tarakan, :infestations, :refresh_error]` | |
|
||
| `[:tarakan, :infestations, :reconcile]` | keys_processed |
|
||
| `[:tarakan, :infestations, :shadow_mismatch]` | during dual-read |
|
||
| `[:tarakan, :repositories, :list_listed_page]` | |
|
||
|
||
**Cutover alerts (required before flag):**
|
||
|
||
| Condition | Severity |
|
||
|-----------|----------|
|
||
| infestations queue depth > 5k for 10m | High |
|
||
| max(refreshed_at) age > 1h while queue empty | High |
|
||
| shadow mismatch rate > threshold | High |
|
||
| refresh p95 > 5s | Medium |
|
||
|
||
---
|
||
|
||
## Rollout Plan
|
||
|
||
```mermaid
|
||
gantt
|
||
title Infestation scale rollout
|
||
dateFormat YYYY-MM-DD
|
||
section Foundation
|
||
Schema_indexes :a1, 2026-07-20, 3d
|
||
Worker_reconcile_refresh :a2, after a1, 4d
|
||
Post_commit_hooks :a3, after a2, 4d
|
||
section Cutover
|
||
Backfill_flag_code :a4, after a3, 3d
|
||
Shadow_compare_ops_flag :ops, after a4, 2d
|
||
section UX_SEO
|
||
Show_pagination :a5, after a2, 4d
|
||
Atlas_LV_uses_flag :a6, after a4, 2d
|
||
SEO_cursors_and_index :a7, after a1, 5d
|
||
section Harden
|
||
Stats_cache_telemetry :a8, after a4, 3d
|
||
Remove_legacy :a9, after ops, 14d
|
||
```
|
||
|
||
### Staged cutover
|
||
|
||
1. Deploy schema + `RefreshPattern` + `Reconcile` (flag off).
|
||
2. Deploy hooks (PR 3).
|
||
3. Backfill.
|
||
4. Shadow-compare with hooks live.
|
||
5. **Ops** enables `read_from_rollup` via runtime env or console (code already supports flag - **not** mixed into “merge PR 6 = enable prod”).
|
||
6. Watch 48h.
|
||
7. Contract legacy after ≥1 quiet release.
|
||
|
||
### Rollback
|
||
|
||
Runtime `read_from_rollup: false`; optional `infestations: 0` queue.
|
||
|
||
---
|
||
|
||
## Risks
|
||
|
||
| Risk | Severity | Mitigation |
|
||
|------|----------|------------|
|
||
| Rollup drift | High | Complete hook matrix (Scans+Work+revalidate); reconcile; lag alerts |
|
||
| Delist key discovery no-op | High | Union rollup memberships + public-ignoring-listing; quarantine test |
|
||
| Window clock decay | High | Hourly `RecomputeWindows` + list last_seen guard; ≤1h partial lag |
|
||
| Window/status wrong | High | Precomputed windows + instance grain; parity tests |
|
||
| Visibility lag | High | `:visibility` hook on `update_visibility` |
|
||
| Write amplification hot keys | Medium | 30s unique coalesce; infestations concurrency 1; dirty-keys if measured |
|
||
| Listing fan-out thousands of keys | Medium | `@max_inline_enqueue` + chain job; scan-only when listing unchanged |
|
||
| Repo-scoped on every assimilate | Medium | Gate on listing_status change only |
|
||
| Sitemap at multi-million | High | Multi-file mandatory at 20k listed |
|
||
| Home activity stampede | Medium | Debounce infestations; document residual collective cost |
|
||
| Pool exhaustion (compose POOL_SIZE=5) | Medium | infestations: 1; raise POOL_SIZE to ≥15 before raising concurrency |
|
||
| Index builds on large tables | Medium | CONCURRENTLY; flag off until valid |
|
||
| Mid-transaction enqueue | Medium | Post-commit only API |
|
||
| Orphan projections on delete | Low | CASCADE FKs on instances + memberships |
|
||
|
||
---
|
||
|
||
## Open Questions
|
||
|
||
1. **Instance grain on show ledger** - **Resolved (product):** **per-canonical ledger + per-repo graph**. Contagion graph uses memberships (capped); instance ledger is one row per canonical with cursor pages. Not per-repo expandable as default ledger.
|
||
2. **Fixed-only patterns on atlas** - **Resolved (product):** **Yes, keep today.** Fixed instances that are in-window still count toward `repo_count` / ranking / chips. Excluding fixed would be a future intentional semantic change + test rewrite, not this design.
|
||
3. **Sitemap multi-file threshold** - **Resolved (engineering):** gate = **20_000 listed** repositories (or 50k URL projection). Product may lower later.
|
||
4. **Representative title stability** - **Optional / deferred:** always open-preferring refresh on recompute (matches current `representative_titles/1` behavior). Pinning on first insert not required for cutover.
|
||
5. **PubSub for infestation updates** - **Optional / deferred:** not required for cutover; static-until-refresh is fine.
|
||
6. **Retention of empty patterns** - **Resolved (product):** **Delete rollup row → show 404.** When a pattern has zero listed+public instances, delete `infestation_patterns` (+ instances/memberships for that key). No soft-delete for SEO stability of `/infestations/:key`.
|
||
|
||
---
|
||
|
||
## Key Decisions
|
||
|
||
| Decision | Rationale |
|
||
|----------|-----------|
|
||
| **Preserve production list/window/status semantics** | Avoid silent ranking changes; dual-read parity is real |
|
||
| **Fixed-in-window instances still count on atlas** | Product: keep today; no fixed-only exclusion |
|
||
| **Pre-ranked fixed-window columns + hourly window recompute + last_seen guard** | O(limit) atlas; bound clock lag ≤ ~1h for quiet patterns |
|
||
| **Instance grain table + membership table** | Exact recompute, status-filter secondary path, graph satellites |
|
||
| **Show: per-canonical ledger + per-repo graph** | Product; graph cap + ledger pages without starving either |
|
||
| **Delete empty pattern rollups (404 show)** | Product; no soft-delete SEO retention |
|
||
| **Not online GROUP BY of memberships for default list** | Dense windows would reintroduce multi-second aggregates |
|
||
| **Async projection + dual-read (not dual-write of facts)** | Source of truth stays canonical/scans |
|
||
| **Post-commit schedule API only** | Assimilate runs inside Multi; avoid transactional Oban |
|
||
| **Delist-safe key discovery (rollup ∪ public-ignoring-listing)** | Quarantine must enqueue; listed-only join returns zero keys |
|
||
| **Full mutate-path matrix (Scans+Work+visibility+promote+revalidate)** | Every public-contribution path covered |
|
||
| **Repo-scoped enqueue only when listing flips** | Avoid O(patterns) on every assimilate |
|
||
| **One Oban job per pattern_key** | Correct unique coalescing; listing enqueues N jobs |
|
||
| **Reconcile + RecomputeWindows ship with foundation** | Drift + clock decay heal before flag |
|
||
| **Hard cutover gates** | Hooks + backfill + shadow-compare + lag alerts |
|
||
| **`list_instances` max 300; page max 100** | Swarm limit 200 works; UI stays bounded |
|
||
| **Split Show graph vs ledger sources** | Caps don’t starve ledger; +N more required |
|
||
| **`list_listed_repositories_page` / stream** | Valid Elixir API; no full dump |
|
||
| **Multi-file sitemap at 20k listed** | Prerequisite for large registry SEO claims |
|
||
| **infestations concurrency 1 vs compose POOL_SIZE 5** | Raise pool before concurrency |
|
||
| **CASCADE FKs on projection tables** | Hard deletes leave no orphans |
|
||
| **No stats_version in v1** | Full recompute last-writer-wins; unique jobs limit races |
|
||
| **Map remains top-N atlas** | Product + SVG limits |
|
||
| **Scale tiers A/B/C** | Honest multi-million claims only for atlas/list/show with prerequisites |
|
||
| **Reject top-N snapshot as primary (Alt F)** | Filters/show still need per-key projection |
|
||
| **Runtime-readable flag; single-node ops** | Instant-ish rollback via `Application.put_env` or restart |
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
- `lib/tarakan/infestations.ex` - online aggregation, window/status, `list_instances/2` list return (max 300; swarm limit 200)
|
||
- `lib/tarakan/finding_memory.ex` - assimilate, pattern_key, checks, `refresh_repository_checks/1`, `assimilate_report_check/3`
|
||
- `lib/tarakan/repositories.ex` - listing, membership notify → revalidate, unbounded listed list, `registry_stats/0`
|
||
- `lib/tarakan/scans.ex` - `stage_review_insert/1`, `do_submit_scan/4`, `broadcast_review_submitted/1`, `update_visibility/4`, `moderate_scan/4`, `revalidate_repository_authority/1`, private `recalculate_repository/2`, public `recalculate_repository_metrics/1`, `list_indexable_findings/0`
|
||
- `lib/tarakan/work.ex` - task submit → `stage_review_insert` + `broadcast_review_submitted`; `promote_pending_repository!/2`
|
||
- `lib/tarakan_web/live/infestation_live/{index,show}.ex` - Show uses one `@instances` for graph + ledger today
|
||
- `lib/tarakan_web/live/repository_live/index.ex` - `refresh_collective/0`
|
||
- `lib/tarakan_web/controllers/seo_controller.ex`
|
||
- `lib/tarakan/sync/repository_sweep.ex`
|
||
- `config/config.exs`, `config/runtime.exs` (`POOL_SIZE` default 10 if unset)
|
||
- `scripts/deploy/deploy.sh`, `deploy/docker/compose.yml` (`POOL_SIZE:-5`)
|
||
- Migrations: canonical memory, pattern_key
|
||
|
||
---
|
||
|
||
## PR Plan
|
||
|
||
Each PR is mergeable as code; **ops flag enable is not a PR merge**. Dependencies tightened after review.
|
||
|
||
### PR 1 - Infestation rollup schema and indexes
|
||
|
||
- **Title:** `Add infestation rollup tables and supporting indexes`
|
||
- **Files:** migrations (patterns, pattern_repos, pattern_instances, window columns, concurrent source indexes); schema modules
|
||
- **Dependencies:** None
|
||
- **Description:** Expand-only. Empty tables. No behavior change.
|
||
|
||
### PR 2 - Refresh worker, recompute, reconcile, window decay
|
||
|
||
- **Title:** `Infestations.RefreshPattern, Reconcile, and RecomputeWindows`
|
||
- **Files:** `infestations.ex` recompute + delist-safe `pattern_keys_for_repository`; `refresh_pattern.ex`; `reconcile.ex`; `recompute_windows.ex`; queue `infestations: 1`; hourly + nightly cron; tests (distinct multi-occurrence, public sample, window recompute from instances only); telemetry
|
||
- **Dependencies:** PR 1
|
||
- **Description:** One-key full refresh; cheap hourly window columns from `infestation_pattern_instances`; Reconcile chained cursor; CASCADE-aware empty pattern cleanup. **No read cutover.**
|
||
|
||
### PR 3 - Post-commit hooks (full matrix)
|
||
|
||
- **Title:** `Schedule infestation refresh after public contribution changes`
|
||
- **Files:** `scans.ex` (submit success, visibility/moderate success, listing promotion, confirmation Multi success - not inside `assimilate_report_check`); `work.ex` (submit success + `promote_pending_repository!`); `finding_memory.ex` (return keys; `record_check` success; no enqueue inside Multi); `revalidate_repository_authority` / membership notify path; `repositories.ex` listing; `schedule_refresh_*_after_commit` with delist-safe discovery; tests: quarantine drops memberships, Work submit, publish promotion, revalidate status, visibility; in-transaction guard
|
||
- **Dependencies:** PR 2
|
||
- **Description:** Complete mutate-path matrix including Work and authority revalidate. Listing-gated repo fan-out only. Projection-only (async jobs).
|
||
|
||
### PR 4 - Backfill, dual-read code, shadow-compare, cutover checklist
|
||
|
||
- **Title:** `Infestation backfill and feature-flagged rollup reads`
|
||
- **Files:** `backfill.ex`; `list_infestations_from_rollup`; `get_infestation` rollup; config; parity tests (window + status + mixed instances); mix task optional; document cutover gates
|
||
- **Dependencies:** **PR 3 hard** (hooks required before any prod flag enable; code may merge after PR 3 only)
|
||
- **Description:** Default `read_from_rollup: false`. Shadow-compare logging. **Does not enable flag in production** - ops step after checklist.
|
||
|
||
### PR 5 - Show: split graph/ledger, page APIs, +N more
|
||
|
||
- **Title:** `Paginate infestation ledger and cap contagion satellites`
|
||
- **Files:** `list_instances_page` (max 100), `list_pattern_repos_page`; keep `list_instances` list compat (**max 300**, swarm 200); `InfestationLive.Show`; components; **required** “+N more in ledger” UI when `repo_count > satellites`; tests
|
||
- **Dependencies:** PR 1 (PR 2 preferred for membership data; can use online fallback until rollup filled)
|
||
- **Description:** Bound memory; no return-shape break for swarm.
|
||
|
||
### PR 6 - Atlas LiveViews read flag; home infestation debounce
|
||
|
||
- **Title:** `Wire infestation LiveViews to rollup flag; debounce home infestations`
|
||
- **Files:** `InfestationLive.Index`, home, explore; home debounce only for infestations; tests
|
||
- **Dependencies:** PR 4, PR 5
|
||
- **Description:** Code paths call `list_infestations` (flag-aware). **Production enable remains ops.** Document residual home collective cost.
|
||
|
||
### PR 7 - Registry page/stream + sitemap Phase A
|
||
|
||
- **Title:** `Cursor-paginated listed repositories and streamed sitemap`
|
||
- **Files:** `list_listed_repositories_page`, `stream_listed_repositories`; findings page/stream; `SEOController`; remove unbounded production dumps; tests
|
||
- **Dependencies:** PR 1 listed index preferred
|
||
- **Description:** Phase A single-file stream + 50k cap.
|
||
|
||
### PR 7b - Sitemap index (Phase B)
|
||
|
||
- **Title:** `Multi-file sitemap index for large registry`
|
||
- **Files:** router/controller sitemap index + child sitemaps; threshold **20_000 listed**
|
||
- **Dependencies:** PR 7
|
||
- **Description:** **Hard prerequisite** before claiming Tier B+ SEO readiness / multi-million listed discovery.
|
||
|
||
### PR 8 - Registry stats ETS + telemetry polish
|
||
|
||
- **Title:** `Cache registry_stats and infestation lag metrics`
|
||
- **Files:** stats cache; telemetry; alert notes
|
||
- **Dependencies:** PR 2 (metrics); can follow PR 4
|
||
- **Description:** ETS TTL; ensure lag/queue alerts used in cutover checklist (alerts defined in PR 2, polished here).
|
||
|
||
### PR 9 - Remove legacy online list path
|
||
|
||
- **Title:** `Remove Infestations legacy GROUP BY list path`
|
||
- **Files:** delete legacy + flag
|
||
- **Dependencies:** Flag on in production ≥1 quiet release
|
||
- **Description:** Contract phase.
|
||
|
||
### Dependency graph
|
||
|
||
```text
|
||
PR1 ──► PR2 ──► PR3 ──► PR4 ──► ops flag ──► PR9
|
||
│ │ ├──► PR6 ────────────┘
|
||
│ └──► PR5 ───────┘
|
||
├──► PR7 ──► PR7b
|
||
└──► PR8 (after PR2/4)
|
||
```
|
||
|
||
**Independently mergeable** means each PR can merge without enabling the flag; **not** that PR 4 may cut over without PR 3.
|
||
|
||
---
|
||
|
||
*End of design document (rev 3 + product answers - approved for implementation).*
|