diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..5432642e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +# Faster local `cargo build` on nightly: +# - Parallel rustc frontend (`-Z threads=8`) +# +# rust-toolchain.toml selects nightly so these flags are valid for `cargo`. +# Do not put `profile.dev.codegen-backend` here: Cargo 1.96 rejects that +# unstable key, and CI/Docker pin rustc 1.96.0 via `RUSTUP_TOOLCHAIN`. +# Optional local Cranelift (nightly only): +# export CARGO_PROFILE_DEV_CODEGEN_BACKEND=cranelift +# CI and Docker set `CARGO_ENCODED_RUSTFLAGS` empty so stable rustc never +# receives `-Z`. +[build] +rustflags = ["-Z", "threads=8"] diff --git a/.cursor/plans/data_branch_cow_58a7ae1f.plan.md b/.cursor/plans/data_branch_cow_58a7ae1f.plan.md new file mode 100644 index 00000000..1e6be36e --- /dev/null +++ b/.cursor/plans/data_branch_cow_58a7ae1f.plan.md @@ -0,0 +1,733 @@ +--- +name: Data Branch CoW +overview: "Implement issue #91 as durable CoW data branches. Shared __branch_base. KoldMergeScan becomes an ordered source list with first-seen PK: main is [hot, cold+__cl mask]; branch is [overlay, freeze, hot, cold+__cl mask]. Same custom scan. HotChild unchanged on main. Freeze GC on closer." +todos: + - id: crate-layout + content: "Scaffold crates/koldstore-branching (PostgreSQL-free) and pg_koldstore/src/branching/; wire workspace deps; crate-architecture.md + //! module docs" + status: pending + - id: phase-0-prereqs + content: "Phase 0: fix SET TABLE publication bug; persist seq watermark on flush fences. pgoutput FULL probe is optional fallback only — seeds do not use WAL." + status: pending + - id: phase-1-activation + content: "Phase 1: branches catalog, GUC, enable_branching (install freeze triggers, create __branch_rows + __branch_base, drain flushes, reject cold). Do not set REPLICA IDENTITY FULL." + status: pending + - id: phase-2-sandbox + content: "Phase 2: ExecutorStart + ProcessUtility fail-closed unmanaged DML/COPY/TRUNCATE while current_branch <> main" + status: pending + - id: phase-3-overlay-dml + content: "Phase 3: __branch_rows + planner rewrite of managed INSERT/UPDATE/DELETE onto overlay; reject unsupported forms" + status: pending + - id: phase-4-preimage-reads + content: "Phase 4: generalize MergeRowStream to an ordered source list (main=[hot,cold]; branch=[overlay,freeze,hot,cold]); three branch-only gates; v1 skip OrderedProgressive on branch; main HotChild unchanged" + status: pending + - id: phase-5-flush-gc + content: "Phase 5: flush postpone; overlay DELETE by branch_id; freeze GC (truncate if last branch, else delete rows no remaining snapshot needs); expiry" + status: pending + - id: phase-6-diff-merge + content: "Phase 6: branch_diff/conflicts; atomic fail_on_conflict merge with lock/fence ordering" + status: pending + - id: phase-7-changes-since + content: "Phase 7: changes_since(branch) — main feed isolation + PRE_COMMIT overlay seq cursor" + status: pending + - id: phase-8-docs-crash + content: "Phase 8: architecture/sql-api docs, observability, crash/prepared-plan/RLS tests" + status: pending +isProject: false +--- + +# Durable Copy-on-Write Data Branches + +**Goal:** Isolated DML workspaces over one frozen committed view of every KoldStore-managed table in a database: create → edit with ordinary SQL → `branch_diff` / `changes_since` → atomic merge or discard, without cloning tables or turning `__cl` into an audit log. + +**Starting point:** Issue [#91](https://github.com/kalamdb/koldstore/issues/91) is the product spec. The protocol probe [`tests/e2e/dml/pgoutput_old_row_cow.rs`](tests/e2e/dml/pgoutput_old_row_cow.rs) already exists. Production capture is still PK-only, ignores `Update.old`, and has no branch catalog. There is no DML rewrite hook today — only `ExecutorEnd`, `ProcessUtility`, and `set_rel_pathlist`. + +--- + +## What the issue got right (keep) + +- Database-scoped branches, not schema branches or physical clones. +- One overlay row per `(branch_id, PK)`; no append-only branch audit in v1. +- `__cl` stays latest-state main feed; branch DML must never write it. +- Lazy freeze of BASE: while any branch is open, a **same-transaction** heap trigger writes OLD/absence **once** into shared `__branch_base`. Cost is O(main mutations), not O(branches × mutations). `__branch_rows` is only that agent’s edits. `__cl` stays async WAL. +- `base_seq` is the public snapshot coordinate; `applied_lsn` is internal fencing. +- Flush/prune postponed while any branch is `active`/`merging` (also sidesteps [#122](https://github.com/kalamdb/koldstore/issues/122) for *future* flushes). +- Freeze GC on closer: drop `__branch_base` rows no remaining open branch needs; `TRUNCATE` when the last pin drops. +- Merge is one atomic three-way `fail_on_conflict` transaction; `main_wins` / `branch_wins` stay out of v1 despite the issue’s SQL examples. +- Fail-closed unmanaged DML while `current_branch <> main`. + +--- + +## Design corrections (do not implement the issue verbatim) + +Independent reviews (GPT + Claude) plus a second pass against this repo and PostgreSQL 15 docs. Status of each correction: + +- **Keep:** 1 (SET TABLE bug), 2 (split enable/fork), 5 (planner rewrite for *branch* DML), 6 (no cold data in v1). +- **Changed:** freeze is a **same-transaction shared log**, not WAL and not per-branch overlay copies (see §3–5). v1 does **not** set `REPLICA IDENTITY FULL` or expand the publication. BASE lookup is `pg_visible_in_snapshot`, not `created_xid > fork_xid`. Freeze GC is required (see §5). +- **v1 table set:** `__cl` + `__branch_rows` (edits) + `__branch_base` (shared freeze). No per-branch seed copies. No `__branch_cl`. + +### 1. Fix `SET TABLE` publication membership — confirmed, still required + +[`reconcile_publication_columns`](crates/pg_koldstore/src/mirror/lifecycle.rs) runs `ALTER PUBLICATION … SET TABLE {one table} (cols)` on rename. PostgreSQL 15 docs: *“The SET clause will replace the list of tables/schemas in the publication with the specified list.”* That drops every other managed table from `koldstore_async_mirror`. Phase 0: `DROP TABLE t` + `ADD TABLE t` in one transaction. + +v1 does **not** set `REPLICA IDENTITY FULL` and does **not** drop/expand column lists for branching. `__cl` stays PK-only pgoutput. The FULL+column-list UPDATE/DELETE error is why we refuse that activation path. + +### 2. Split `enable_branching()` from `branch()` — confirmed, but without FULL + +Installing freeze triggers and `CREATE __branch_rows` / `__branch_base` assigns an XID. [`require_no_assigned_xid_for_slot_provision`](crates/pg_koldstore/src/mirror/lifecycle.rs) still applies if `branch()` then peeks the slot for `base_seq`. + +- `koldstore.enable_branching()`: operator opt-in; create `__branch_rows` + `__branch_base`; install freeze triggers (same pattern as the existing PK guard); drain in-flight flushes; refuse cold segments. **No** `ALTER TABLE … REPLICA IDENTITY FULL`. +- `koldstore.branch(name)`: refuses until `branching_enabled`; arm capture; fence for `base_seq`/`base_lsn`; store `base_snapshot`; insert catalog row. No DDL. No per-branch heap and no copy of main. + +Still persist `seq_high_watermark` on every `__cl` write (flush fences currently skip `record_applied_lsn`). Derive `base_seq` inside the apply lock. + +### 3. 100 open branches must not copy OLD 100 times + +WAL-applied seeds still lose the race (heap commits first). Triggers stay. **Per-branch overlay seeds do not scale:** + +| Design | Main `UPDATE` with 100 open branches | Branch `SELECT` | +|---|---|---| +| Seed each overlay | 100 inserts of the same OLD | This overlay only (reads scale) | +| Shared `__branch_base` | **1 insert** | This overlay + 1 freeze probe (reads scale) | + +The write axis that matters is **main mutations while any branch is open**, not agent count. Overlay size is **that agent’s edits**, not `main_rows × branches`. `branch(name)` copies nothing. + +```mermaid +flowchart TD + upd[Main UPDATE] + freeze["__branch_base one OLD"] + upd --> freeze + freeze --> r1[agent1 SELECT] + freeze --> r2[agent2 SELECT] + freeze --> rN[agent100 SELECT] + o1[agent1 overlay edits] + o2[agent2 overlay edits] + o1 --> r1 + o2 --> r2 +``` + +`__branch_rows` holds **edits only** (no `is_seed` column, no fan-out). + +AFTER STATEMENT trigger (PK-guard pattern), same heap transaction: + +- `UPDATE`/`DELETE`: insert OLD into `__branch_base` with `created_xid = pg_current_xact_id()`, `prior_cl_seq` from current `__cl` (CDC identity only), `was_present = true`. +- `INSERT`: absence row, `was_present = false`. +- `ON CONFLICT (pk, created_xid) DO NOTHING` (same txn touching the PK twice keeps the first OLD). +- Rollback/savepoint undoes the freeze with the heap write. +- No-op when `active_branch_count = 0`. Skip flush cleanup; do **not** skip `merge_branch` (other branches need that OLD). + +Store `created_xid` as an **`xid8` column**. Do not use heap `xmin` for lookup — `VACUUM FREEZE` rewrites `xmin` and would break BASE. + +WAL fills `__cl` only. + +### 4. BASE lookup is snapshot visibility, not `created_xid > base_xid` + +**Do not** resolve BASE as `created_xid > fork_xid`. Xid order is not commit order: a txn that started before fork (lower xid) can commit after fork. That freeze row would be skipped and the branch would fall through to the post-fork heap value. + +`prior_cl_seq >= base_seq` is also unsafe as the lookup key: the trigger reads `__cl` under apply lag, so two later mutations can stamp the same stale seq. + +**v1 lookup:** fork stores `base_snapshot pg_snapshot` (`pg_current_snapshot()` after capture is armed). `base_seq` / `base_lsn` remain the public CDC coordinates. + +For PK `K` on branch B: + +```text +first __branch_base row for K + where NOT pg_visible_in_snapshot(created_xid, B.base_snapshot) + order by created_xid +``` + +That OLD/absence is BASE. If none, current `KoldMergeScan` is BASE (main never replaced `K` after this snapshot). + +In-flight-at-fork: the writer’s xid is in the snapshot’s in-progress list, so `pg_visible_in_snapshot` is false; after it commits, the freeze is visible now and counts as BASE. Arm capture (`creating`) **before** taking the snapshot so that txn’s trigger actually ran. + +Two branches, two snapshots, one freeze log: A sees the first freeze not visible in A’s snapshot; B sees the first not visible in B’s. No copies. + +### 5. Verdict: this design is the right complexity, and it scales — if we GC + +**Good, not overbuilt.** The extra moving part versus “copy OLD into every overlay” is one shared heap plus a snapshot probe. That replaces an `O(branches)` write tax with an `O(1)` write. Overlay DML, merge, and `changes_since` stay simple. The snapshot lookup is the only PostgreSQL-hard piece; it is load-bearing (xid inequality is incorrect). Do not add WAL FULL, `__branch_cl`, or per-branch seeds on top. + +**Where complexity lives (keep it here):** freeze trigger, `base_snapshot`, freeze GC, ordered merge-scan **source list**, three branch-only HotChild/native gates. Do not extend OrderedProgressive in v1. Do not add a second custom scan. + +**Scale (100 branches):** + +| Axis | Cost | +|---|---| +| `branch(name)` | Catalog row. No copy. | +| Main DML | One `__branch_base` insert per mutated PK, independent of 100. | +| Branch `SELECT` | This overlay + one freeze probe + existing scan. Other agents are not opened. | +| Overlay storage | That agent’s edits only. | +| Freeze storage | Main mutations **while any branch is open**, not `rows × branches`. | + +The freeze table is the pin. A forgotten long-lived branch plus heavy main DML will grow `__branch_base` until that branch is gone. That is why GC and `expires_at` are required, not optional. 100 well-behaved short branches are cheap. One abandoned branch is the operational risk. + +**Do not simplify** by taking `AccessExclusiveLock` on every managed table at fork just to use `seq`/`xid` inequality. Worse UX, still need a freeze log. + +### 6. Yes — clean `__branch_base` when no open branch still needs the row + +A freeze row is a preimage for branches that forked **before** that mutation committed. Once every such branch is `merged`, `discarded`, or expired, the row is garbage. + +**Need predicate (exact, not “oldest snapshot”):** branch B still needs freeze row F iff `NOT pg_visible_in_snapshot(F.created_xid, B.base_snapshot)` and B is `creating` / `active` / `merging`. Concurrent forks can have incomparable in-progress sets, so “visible in the oldest snapshot” is **not** a proof. + +```sql +-- per managed table, after a closer (not on every main DML) +DELETE FROM koldstore.__branch_base f +WHERE NOT EXISTS ( + SELECT 1 FROM koldstore.branches b + WHERE b.status IN ('creating', 'active', 'merging') + AND NOT pg_visible_in_snapshot(f.created_xid, b.base_snapshot) +); +``` + +**When to run** + +- **Last closer** (`active_branch_count` drops to 0 after merge, discard, expiry, or `disable_branching`): `TRUNCATE __branch_base` on every managed table. Fast path. Freeze trigger already no-ops at count 0, so new inserts stop. +- **Some branches remain:** run the `DELETE` above. Same closer paths: successful `merge_branch`, `discard_branch`, expiry worker. Async job (do not add a freeze-table scan to the user’s merge/discard transaction beyond the catalog update). Overlay cleanup stays `DELETE FROM __branch_rows WHERE branch_id = $1`. +- **Never** on the main DML freeze trigger. That would tax the write path we just made `O(1)`. + +`merged` / `discarded` catalog rows are retained for status but **do not** pin freeze. Only `creating` / `active` / `merging` pin. + +Optional safety: if `__branch_base` exceeds a size/row quota while branches exist, enqueue the same GC job and/or error new forks until expiry catches up. Status JSON should expose freeze-row counts. + +### 7. Two different “trigger” questions — do not mix them + +- **Main → shared freeze log:** heap triggers into `__branch_base`. Correct (§3). +- **Branch session DML on managed tables:** still a **planner rewrite**, not BEFORE triggers. Heap BEFORE triggers evaluate `WHERE` against main, miss overlay-only rows, and break `RETURNING`. `ExecutorStart` is only a fail-closed guard. + +Supported branch DML forms first; reject with a specific error: `MERGE`, `COPY`, `WHERE CURRENT OF`, `FROM`-list UPDATE, `FOR UPDATE/SHARE`, system-column `RETURNING`, PK/segment-order mutation, user triggers/FKs/exclusion constraints. + +### 8. Already-cold data is a hard v1 gate — keep + +Flush postpone does **not** rehydrate existing Parquet. Native merge DML and uniqueness are still hot-only ([#122](https://github.com/kalamdb/koldstore/issues/122)). + +**v1:** `enable_branching()` / `branch()` refuse if any managed table has cold segments. Combined with postpone, every merge target stays on the heap. Revisit after #122. + +### 9. Decode/apply byte budget — optional now + +v1 does not put full rows through pgoutput. Keep PK-only apply. Byte-budgeted peek and `UnchangedToast` handling remain useful hardening but are **not** on the seed path. The existing [`pgoutput_old_row_cow.rs`](tests/e2e/dml/pgoutput_old_row_cow.rs) probe stays as a fallback experiment if triggers ever prove too expensive. + +--- + +## Per-managed-table artifacts (end state) + +Example `public.tasks` when branching is enabled: + +- `koldstore.public_tasks__cl` — main change-log (unchanged) +- `koldstore.public_tasks__branch_rows` — **this branch’s edits only** +- `koldstore.public_tasks__branch_base` — **shared** freeze log (one OLD/absence per main mutation, not per branch) + +No `__branch_cl`. `unmanage_table` drops all three plus the PK guard and freeze trigger. + +```mermaid +flowchart LR + src["public.tasks heap"] + cl["__cl"] + rows["__branch_rows"] + freeze["__branch_base shared"] + src -->|"async WAL"| cl + src -->|"same txn trigger once"| freeze + rows -->|"branch DML"| rows +``` + +### When created + +- **`manage_table` today:** `__cl` + indexes + PK guard + PK-only publication. +- **`manage_table` while `branching_enabled`:** plus empty `__branch_rows` and `__branch_base` + freeze trigger. +- **`enable_branching()`:** create both heaps if missing; install trigger. No row copy. No replica-identity change. +- **`branch(name)`:** catalog only (`base_seq`, `base_lsn`, `base_snapshot`). No per-branch copy of main. + +### 1. `__cl` — unchanged + +Same DDL as today. WAL applier only. Main `changes_since`. + +### 2. `__branch_rows` — edits only + +```sql +CREATE TABLE koldstore.___branch_rows ( + "branch_id" uuid NOT NULL, + , + "op" smallint NOT NULL, -- 1/2/3; 3 = branch tombstone + "seq" bigint NOT NULL, -- per-branch PRE_COMMIT seq + "schema_version" integer NOT NULL, + "created_at" timestamptz NOT NULL, + "updated_at" timestamptz NOT NULL, + PRIMARY KEY ("branch_id", ) +); +CREATE INDEX ON koldstore._
__branch_rows ("branch_id", "seq"); +``` + +Writer: branch DML only. 100 other branches do not appear in this query (`WHERE branch_id = $1`). + +### 3. `__branch_base` — shared freeze (the 100-branch answer) + +```sql +CREATE TABLE koldstore._
__branch_base ( + NOT NULL, + "created_xid" xid8 NOT NULL, -- pg_current_xact_id() in the trigger + "prior_cl_seq" bigint NOT NULL, -- seq already published on main + "was_present" boolean NOT NULL, + "schema_version" integer NOT NULL, + , + PRIMARY KEY (, "created_xid") +); +CREATE INDEX ON koldstore._
__branch_base ("created_xid"); +``` + +One insert per main statement that changes a PK, regardless of branch count. `prior_cl_seq` is the seq a main subscriber already has for that PK. + +### Query path — one KoldMergeScan, ordered source list (not a second scanner) + +Do **not** store branch rows in Parquet or the cold catalog. Cold is published main history; `__branch_rows` is a heap that must take ordinary DML in the same snapshot. The thing in common is the **PK-keyed winner**, which [`KoldMergeScan`](docs/architecture/scanning-table.md) already is: hot heap + `__cl` tombstone mask + cold Parquet ([`NewestFirstWinnerResolver`](crates/koldstore-merge/src/core/resolver.rs), [`MirrorOverlay`](crates/koldstore-merge/src/core/overlay.rs), [`execute.rs`](crates/pg_koldstore/src/merge_scan/pg/execute.rs)). + +User-facing fallback when `current_branch` is `agent-42`: + +```text +branch overlay → freeze BASE → hot heap → cold Parquet + (__cl tombstones already mask cold, unchanged) +``` + +That is the same custom scan node, extra **front layers**, then today’s MainComposite. Reads never loop over other branches (`__branch_rows WHERE branch_id = $current` only). + +```mermaid +flowchart TD + q["SELECT on managed table"] + q --> km[KoldMergeScan] + km --> b["1 BranchOverlay this branch_id"] + km --> f["2 Freeze first not visible in base_snapshot"] + km --> h["3 Hot native child"] + km --> m["4 cl tombstone mask"] + km --> c["5 Cold Parquet"] + b -->|live or tombstone| emit[Emit or hide] + b -->|miss| f + f -->|OLD or absence| emit + f -->|miss| mainComp[Existing hot plus mirror plus cold] + h --> mainComp + m --> mainComp + c --> mainComp +``` + +**What is shared vs what is not** + +- Cold / `__cl` stay Parquet + mirror heap. Branch stays `__branch_rows` / `__branch_base` heaps. Do not unify storage. +- Winner protocol to reuse: `NewestFirstWinnerResolver` is **first-seen PK**, not a seq compare across unrelated timelines. Feed overlay, then freeze, then today’s hot, then today’s cold. Do not clone [`execute.rs`](crates/pg_koldstore/src/merge_scan/pg/execute.rs). Do not add `KoldBranchScan`. + +Freeze is not another cold segment. On the main timeline it is older than current heap; on the branch view it must win over hot — which is exactly “supply it before hot so `seen` already contains the PK.” + +### Current KoldMergeScan — we can extend it without a rewrite + +Verified against the code (not the docs-only story). + +**Control flow today** + +1. Planner [`set_rel_pathlist`](crates/pg_koldstore/src/merge_scan/pg.rs): unmanaged / not SELECT → native. **`segment_count == 0` → return (no CustomScan).** `cold_side_proven_empty` → return. Else install the KoldMergeScan portfolio. +2. [`begin_custom_scan`](crates/pg_koldstore/src/merge_scan/pg.rs): exact-PK uninstrumented → `HotProbeState::Pending` (first `ExecProcNode` on the native child; **no overlay, no Parquet**). Runtime cold-empty + delegate-safe → `Delegate` (pure child). Else `initialize_fallback_scan`. +3. [`execute_scan_sources_with_profile`](crates/pg_koldstore/src/merge_scan/pg/execute.rs): `probe_hot_point_hit` (SPI, before Parquet). If no cold + child exists → `EmitPath::HotChild`. Else `MergeRowStream`. +4. [`MergeRowStream::next_materialized`](crates/pg_koldstore/src/merge_scan/pg/execute.rs): exhaust **hot** pages via `resolve_hot_batch` (PKs enter `seen`); then `__cl` `mask_older_pks`; then **cold** pages via `resolve_cold_batch` (skips `seen`). `MirrorOverlay` only masks cold. + +`ScanEmitMode`: `HotChild` | `Buffer` | `Stream`. `EmitPath`: `HotChild`, `HotNative`, `ColdNative`, `MergeStream`, `OrderedMergeNative`, `UnorderedHotFirst`. + +**The seam that already matches branching** + +[`take_unseen_ordered`](crates/koldstore-merge/src/core/resolver.rs) drops a PK if it is already in `seen`, including deleted/tombstone identities. Hot-then-cold is “higher priority source first.” Overlay and freeze are the same shape (`HotRow` live or `deleted`). `__cl` stays a **mask on cold only**, not a source. + +### Suggested merge-scan design: ordered source list (not a phase enum) + +After reading `MergeRowStream`, I do **not** recommend a different winner or a second CustomScan. I **do** recommend not bolting `Overlay`/`Freeze` as copy-pasted arms next to `hot_phase_done`. + +Today the stream is a hardcoded pair of fields: + +```text +struct MergeRowStream { hot: HotMergeSource, cold: ColdRowStream, overlay: MirrorOverlay, hot_phase_done, ... } +``` + +That is why adding branch looks like a fork. Make the **Stream path** (not HotChild) a list drained in priority order. Same loop for both products: + +```text +main: [ Hot, Cold+__cl mask ] +branch: [ Overlay, Freeze, Hot, Cold+__cl mask ] +``` + +```text +loop: + emit queued winners from the current source + if that source is exhausted → advance + load one batch → resolve_hot_batch / resolve_cold_batch (first-seen PK) + cold batches run retain_unmasked(__cl) first, as today +``` + +`HotMergeSource` already abstracts SPI vs native child. Overlay and freeze are additional SPI sources (SQL from `koldstore-branching`), not new emit modes. Cold stays `ColdRowStream`; `__cl` stays attached to **that** source as a mask. + +**HotChild stays a bypass, not a source.** `EmitPath::HotChild` / `Pending` / `Delegate` never enter `MergeRowStream`. They remain valid iff the logical list would be `[Hot]` only: session is `main` and cold cannot contribute. A branch session always has Overlay/Freeze in the list, so it always uses Stream (or Exact PK probes) even when the cold manifest is empty. + +**Build the list in one place** (`execute_scan_sources` / `prepare_merged_stream`): + +1. If `current_branch <> main`: push Overlay, push Freeze. +2. Push Hot (`HotMergeSource::NativeChild` or `SpiJson`, same as today). +3. If cold stream present: push Cold with `MirrorOverlay`. + +No `if branch { overlay_phase } else { hot_phase }` in `next_materialized`. + +**Refactor order (so main does not regress)** + +1. Change `MergeRowStream` internals to `Vec` with **two** entries (hot, cold). Existing merge-scan tests must stay green. Do not change `begin_custom_scan` HotChild. +2. Attach Overlay/Freeze when the session is a branch. Add the three gates below. +3. Leave `OrderedProgressive` on the **two-source** (hot, cold) constructor only. Branch v1 does not call it. + +**v1 branch portfolio:** `ExactPrimaryKey` + `UnorderedHotFirst` + `GeneralMerge`. PostgreSQL `Sort` for `ORDER BY`. Revisit ordered-progressive-on-branch only after the source list is proven on unordered/exact-PK. + +**Three branch-only gates (do not touch these on main)** + +| Gate | File | Branch session | Main session | +|---|---|---|---| +| Empty manifest native return | `set_rel_pathlist` ~417 | Skip; always install KoldMergeScan | Unchanged | +| `HotProbeState::Pending` / `Delegate` | `begin_custom_scan` ~666–701 | Skip; go to fallback | Unchanged | +| `cold_stream None` → `HotChild` | `execute.rs` ~683–687 | Skip; Stream with `[Overlay, Freeze, Hot]` | Unchanged | + +Exact PK: probe overlay SPI, then freeze SPI, then existing `probe_hot_point_hit`. A heap child hit must not win if overlay/freeze has that PK. + +**What I am not suggesting** + +- A new CustomScan name, Parquet for branches, or treating freeze as another cold segment. +- A new `RowSource` / priority-overlay trait in `koldstore-merge`. +- Running overlay on HotChild. +- Teaching OrderedProgressive N frontiers in v1. +- Making `koldstore-merge` depend on branching. + +**Honest complexity** + +- **Right amount:** one `Vec` drain loop, two SPI loaders next to [`mirror.rs`](crates/pg_koldstore/src/merge_scan/pg/mirror.rs), three `if branch` gates, EXPLAIN counters per source. +- **Too much:** `MergePhase` with four copy-pasted `next_materialized` arms; OrderedProgressive-on-branch in the first cut. +- **Must not regress:** empty-manifest native plans and `EmitPath::HotChild` when the session is `main`, even if other sessions have branches open ([AGENTS.md](AGENTS.md)). + +**Crate split:** `koldstore-merge` stays free of branch catalog types. `koldstore-branching` plans overlay/freeze SQL. [`merge_scan`](crates/pg_koldstore/src/merge_scan) owns the source list. `LogicalSource` lives next to `HotMergeSource` in the adapter (PostgreSQL streams), not as branch types in the merge crate. + +v1 still postpones flush / refuses existing cold, so Cold may be absent from the list while branches are open. The same Stream path still accepts Cold later ([#122](https://github.com/kalamdb/koldstore/issues/122)) without a second merge. + +Overlay discard: `DELETE FROM __branch_rows WHERE branch_id = $1`. Freeze GC is §6: `TRUNCATE` when the last pin drops, else `DELETE` rows no remaining `creating`/`active`/`merging` snapshot still needs. Do not use “oldest snapshot” as the need test. + +### Database catalog + +`koldstore.branches`: `base_snapshot pg_snapshot` (lookup), `base_seq` / `base_lsn` (public CDC). Extend `async_mirror_state` with branching flags. Schema catalog stores OIDs of `__cl`, `__branch_rows`, `__branch_base`. + +--- + +## Architecture + +```mermaid +flowchart TD + subgraph mainPath [Main session] + HeapDML[Native heap INSERT UPDATE DELETE] + FreezeTrig[AFTER STATEMENT freeze trigger] + Freeze["__branch_base shared"] + WAL[PK-only pgoutput] + Applier[Serialized DB applier] + CL["__cl latest-state"] + HeapDML --> FreezeTrig + FreezeTrig -->|"same txn one OLD or absence"| Freeze + HeapDML --> WAL --> Applier --> CL + end + + subgraph branchPath [Non-main session] + GUC[current_branch GUC] + Planner[Planner rewrite DML] + Overlay["__branch_rows this branch"] + Scan[KoldMergeScan source list] + GUC --> Planner --> Overlay + GUC --> Scan + Scan --> Overlay + Scan --> Freeze + Scan --> Heap[Hot child] + Scan --> Cold[Cold Parquet] + end + + Overlay --> Diff[branch_diff] + Overlay --> Merge[merge_branch heap txn] + Merge --> HeapDML +``` + +**Session:** `koldstore.current_branch` Userset GUC (default `main`) plus `set_current_branch` / `current_branch()`. `check_hook` validates syntax only (no catalog). `assign_hook` calls `ResetPlanCache()`. Resolve name → `branch_id` + status at statement start. + +**Main fast path:** if no open branches, freeze trigger returns immediately; heap DML and `__cl` apply unchanged (PK-only WAL). Freeze I/O only when `active_branch_count > 0`. Overlay I/O only for a non-main session (and only that `branch_id`). + +**Branch SELECT — ordered sources inside KoldMergeScan:** + +1. Overlay (`__branch_rows` for this `branch_id`). +2. Freeze (`__branch_base`, first not visible in fork snapshot). +3. Hot native child. +4. Cold Parquet, with `__cl` tombstone mask on that source only. + +Main session list is `[Hot, Cold+mask]` or HotChild when cold cannot contribute. + +Name both branch heaps like `__cl` (hashed, OID in catalog). Reuse `bounded_identifier`; do not copy the hash. + +--- + +## Crate and folder layout (maintainable split) + +Follow [crate-architecture.md](docs/architecture/crate-architecture.md): `pgrx` stays in `pg_koldstore`; domain logic in the lowest PostgreSQL-free layer. **Do not** put branch catalog/FSM/SQL in `koldstore-merge`. Do **not** add a new winner type; feed overlay/freeze into `NewestFirstWinnerResolver` first. Do not put branch types in `koldstore-catalog`. + +### New library: `crates/koldstore-branching` + +PostgreSQL-free. Workspace member + `workspace.dependencies` entry, same as merge/mirror. Depends on `koldstore-common` and `koldstore-wal-mirror` (reuse, not reimplement). May depend on `koldstore-merge` for cursor/PK helpers. **`koldstore-merge` must not depend on `koldstore-branching`** (no cycle). Flush/migrate stay unaware; the adapter calls them. + +```text +crates/koldstore-branching/src/ + lib.rs //! crate contract + re-exports + catalog.rs branch row types, status FSM, ACL predicates + overlay.rs plan __branch_rows DDL, branch UPSERT SQL + freeze.rs plan __branch_base DDL + AFTER STATEMENT freeze trigger SQL + gc.rs overlay-delete-by-branch + freeze need-predicate (TRUNCATE vs DELETE) + resolve.rs source-list order: overlay, freeze, hot, cold + scan.rs build the KoldMergeScan source list; no second custom scan + diff.rs net overlay vs BASE classification + merge.rs fail_on_conflict three-way rules (no SQL execution) + dml.rs which INSERT/UPDATE/DELETE forms are supported + changes.rs overlay changes_since cursor plan (delegates to merge changelog) + flush_gate.rs postpone/admission predicates +``` + +Every file starts with `//!`. Public functions document purpose, invariants, and `# Errors`. + +### Thin adapter: `crates/pg_koldstore/src/branching/` + +All extension branching code lives here. Other `pg_koldstore` modules get **one-line call sites**, not branch logic. + +```text +crates/pg_koldstore/src/branching/ + mod.rs //! adapter docs; pub(crate) modules + sql.rs #[pg_extern] wrappers (SQL contract in rustdoc) + session.rs resolve GUC → branch_id/status per statement + guc.rs current_branch GUC; assign_hook ResetPlanCache + catalog.rs SPI for koldstore.branches + hooks.rs ExecutorStart guard + planner rewrite + utility rejects + freeze.rs execute freeze-trigger DDL (SPI); skip path for flush cleanup + gc.rs run overlay delete + freeze TRUNCATE/DELETE after closers + scan.rs build source list when branch <> main; HotChild forbidden + flush.rs admission called from sql/flush + events.rs changes_since(branch) dispatch +``` + +`lib.rs` adds `pub mod branching;`. Hook registration stays in [`hooks/mod.rs`](crates/pg_koldstore/src/hooks/mod.rs) as `branching::register_hooks()`. GUC `define_gucs()` calls `branching::guc::define()`. Do not scatter branch SQL under `sql/` or new files next to `mirror/apply.rs`. + +### What stays in existing crates (minimal patches only) + +These are capture/flush/scan infrastructure, not branch product code: + +- [`koldstore-wal-mirror`](crates/koldstore-wal-mirror): export `bounded_identifier`; SET TABLE membership fix. Overlay/freeze SQL lives in `koldstore-branching` and **calls** `MirrorColumn`, `SqlStatement`, `quote_ident`, mirroring [`plan_mirror_pk_guard`](crates/koldstore-wal-mirror/src/mirror/guard.rs). +- [`pg_koldstore/src/mirror/apply.rs`](crates/pg_koldstore/src/mirror/apply.rs): persist seq watermark only. Do **not** write `__branch_base` from WAL. +- Freeze trigger install next to PK guard in manage/enable_branching. +- [`pg_koldstore/src/merge_scan`](crates/pg_koldstore/src/merge_scan): main/unset `current_branch` unchanged, including `EmitPath::HotChild` and empty-manifest native paths. Branch session: always `KoldMergeScan`, attach overlay+freeze layers, never `HotChild`. One-line call into `branching::scan`. Do not clone `execute.rs`. +- [`koldstore-setup`](crates/koldstore-setup): add `koldstore.branches` to `REQUIRED_CATALOG_TABLES`; DDL still in [`koldstore--0.1.0.sql`](crates/pg_koldstore/sql/koldstore--0.1.0.sql). +- [`koldstore-merge`](crates/koldstore-merge): no branch catalog types and no new overlay trait in v1. Reuse `NewestFirstWinnerResolver` (first-seen PK), `MirrorOverlay`, `changes_since` / `ChangeCursor`, `SimplePkPredicate`. Overlay/freeze are extra sources on the adapter list. Do not fold freeze SQL into this crate. + +```mermaid +flowchart BT + common[koldstore-common] + mirror[koldstore-wal-mirror] + merge[koldstore-merge] + branching[koldstore-branching] + pg[pg_koldstore] + mirror --> common + merge --> common + merge --> mirror + branching --> common + branching --> mirror + branching --> merge + pg --> branching + pg --> merge + pg --> mirror +``` + +--- + +## Reuse existing helpers — do not duplicate + +If a planner, namer, fence, cursor, or GUC pattern already exists, call it. New code is the branch FSM, overlay+freeze plans, BASE/three-way rules, generic merge-layer types, and the adapter. + +- **Naming / SQL AST:** `quote_ident`, `quote_qualified_ident`, `SqlStatement`, `MirrorColumn`, `PrimaryKeyShape`, `mirror_relation_for_source`, export `bounded_identifier` for `__branch_rows` and `__branch_base` suffixes. Do not copy the FNV hash or invent a second identifier scheme. +- **Overlay / freeze DDL:** follow `plan_mirror_schema_with_order_key` — `plan_overlay_schema` and `plan_freeze_schema` in `koldstore-branching`. Do not clone `__cl` (overlay is per-branch edits; freeze is shared versioned OLD). +- **WAL fence / seq:** `wait_for_async_mirror`, `capture_durable_wal_fence`, `BoundedApplyRequest`, `next_id_after`. Fork and merge catch-up call these; do not write a second peeker. +- **Decode:** production apply still ignores `Update.old`. Freeze does not use pgoutput. Keep the existing FULL probe as an optional fallback only. +- **PK extract:** `pk_identity`, `primary_key_cells`, `PkBindColumn` from apply_row for overlay/freeze binds. +- **DML form detection:** `simple_pk_delete_supported`, `extract_simple_pk_delete_predicate`, `plan_managed_*_effect` in `koldstore-merge`. Branch rewrite uses these to accept/reject statements. +- **Merge scan:** generalize `MergeRowStream` to an ordered source list (main `[hot, cold]`, branch `[overlay, freeze, hot, cold]`). Reuse `NewestFirstWinnerResolver`, `HotMergeSource`, `mirror.rs`. Do not clone `execute.rs`, do not add `KoldBranchScan`, do not put branch rows in Parquet, do not extend `OrderedProgressive` in v1. Do not change `EmitPath::HotChild` on main. +- **Change feed:** `koldstore-merge` `changes_since` / `ChangeCursor` / `plan_mirror_changes_since`. Main `branch => main` stays [`sql/events/mod.rs`](crates/pg_koldstore/src/sql/events/mod.rs). Overlay feed reuses the exclusive-seq helper, not a new pagination algorithm. +- **GUC / session:** copy the `koldstore.user_id` pattern in [`guc.rs`](crates/pg_koldstore/src/guc.rs) / [`sql/session.rs`](crates/pg_koldstore/src/sql/session.rs). Internal merge writes reuse `internal_system_write`, do not add a user-settable bypass. +- **Catalog / managed OIDs:** [`catalog/cache.rs`](crates/pg_koldstore/src/catalog/cache.rs) `is_managed_relation` for fail-closed unmanaged DML. +- **Locks:** existing apply/lifecycle advisory locks (`lock_slot`, `LIFECYCLE_LOCK_NAMESPACE`). Do not invent a parallel lock namespace without documenting why. +- **Flush gate:** call into existing `enqueue_flush_job_if_due` / `flush_table_pg_impl` with an admission check from `flush_gate.rs`. Do not duplicate the job queue. +- **Status JSON:** extend `table_status` / `async_mirror_status` builders; do not add a third status object model. +- **PK / order guards:** existing BEFORE UPDATE guard. Freeze triggers are a second source-heap trigger family, planned the same way. Do not fold freeze logic into the PK-guard function. + +Rule for implementers: before writing a helper, grep the workspace. If it exists, import it. If it is `pub(crate)` in the wrong crate, export it rather than copy it. + +--- + +## Documentation and comments (required in the same change) + +Architecture docs must change with this feature ([AGENTS.md](AGENTS.md)). Every new `lib.rs` / module starts with `//!`. `#[pg_extern]` wrappers document the SQL contract and the library function they delegate to. + +New: [`docs/architecture/data-branches.md`](docs/architecture/data-branches.md) — product semantics, activation vs fork, why freeze is shared, KoldMergeScan ordered source list (not Parquet for branches, not a second custom scan), snapshot BASE resolution, freeze GC, DML rewrite, flush postpone, merge locking, `changes_since`, crate map. + +Update in the same PRs that change the contract: + +- [`docs/architecture/crate-architecture.md`](docs/architecture/crate-architecture.md) — add `koldstore-branching`; “Where New Code Goes”: overlay/freeze SQL in `koldstore-branching`, merge executor phases in `pg_koldstore::merge_scan`. +- [`docs/architecture/dml-table.md`](docs/architecture/dml-table.md), [`mirror-capture.md`](docs/architecture/mirror-capture.md), [`flushing-table.md`](docs/architecture/flushing-table.md), [`scanning-table.md`](docs/architecture/scanning-table.md), [`manage-table.md`](docs/architecture/manage-table.md) +- [`docs/sql-api.md`](docs/sql-api.md) — branching SQL + `changes_since(..., branch)` +- [`docs/limitations.md`](docs/limitations.md) — freeze-trigger cost while branches are open (O(main DML), not O(branches)); freeze storage is pinned until every needing branch is merged/discarded/expired; no cold-data branching until #122; unsupported DML forms; sequence/`nextval` leak + +Do not add architecture-doc churn for purely internal refactors. Comments explain invariants (`capture armed before base_snapshot`, `SET TABLE replaces publication membership`, `freeze INSERT ON CONFLICT DO NOTHING`, `VACUUM FREEZE must not be used as lookup xmin`, `overlay has no is_seed copies`, `merged/discarded do not pin freeze`), not what the next line does. + +--- + +## `changes_since` and branches + +Today: table-scoped exclusive `seq` cursor over `__cl` + cold Parquet; seq allocated by the one DB applier. Latest-state, not a full event log. [`docs/sql-api.md`](docs/sql-api.md), [`crates/pg_koldstore/src/sql/events/mod.rs`](crates/pg_koldstore/src/sql/events/mod.rs). + +**Invariants** + +- Overlay writes never touch `__cl` or `seq_high_watermark`. Production CDC stays linear main history while branches are open. +- Merge writes ordinary main heap DML; the applier then emits **per-PK** main feed events (not one “merge” event). +- Main and branch cursors are **not** interchangeable. + +**API** + +```sql +koldstore.changes_since( + table_name, since_seq, limit_rows, last_rows, + branch => text -- default current_branch() +) +``` + +| `branch` | Behavior | +|---|---| +| `main` | Existing `__cl` + cold cursor. Unchanged. Call this from a branch session to read production CDC. | +| other | Overlay cursor for that branch+table (edits only). `source = 'branch'`. `row_image` from overlay. No cold. Freeze rows are not branch-feed events. | + +If the session is non-main and `branch` is omitted, use the session branch — do **not** silently return main (agent footgun). + +**Branch seq (commit-ordered):** do not stamp seq at DML time (T1=5, T2=6, T2 commits first → consumer advances past 6 and misses 5). At `PRE_COMMIT`, take the branch-row lock, allocate `branch_seq`, stamp overlay rows written in this xid. Per-branch seq space for real edits only. + +Freeze rows may still carry **prior `__cl.seq`** so a client that already synced main sees the same seq on the frozen image when reconstructing BASE. They are not overlay events. + +`branch_diff` is the net BASE vs SOURCE review API (overlay PKs only). `changes_since` on a branch is incremental overlay catch-up of real edits only. + +E2E must prove: branch DML absent from main feed; main DML still visible while branches exist; after merge, expected PKs appear with new main seqs; overlay `changes_since` sees branch writes in commit order and does not mix with main seqs; 100 concurrent branches share freeze rows (one `__branch_base` insert per main UPDATE, not 100). + +--- + +## Public SQL (v1) + +Lifecycle: `enable_branching()`, `disable_branching()`, `branch(name)`, `list_branches()`, `branch_status(name)`, `set_current_branch`, `current_branch()`, `discard_branch(name)`. + +Review: `branch_diff`, `branch_conflicts`, `can_merge`. + +Merge: `merge_branch(name)` only (`conflict_policy` omitted or must be `fail_on_conflict`). Reject `main_wins` / `branch_wins`. + +GUC: `SET koldstore.current_branch = 'test1'`. `main` reserved. + +ACL v1: creator + superuser / table owner for switch+write+diff; merge requires table ownership on every managed table (stronger). Document as preview; full grants later ([#120](https://github.com/kalamdb/koldstore/issues/120)). + +--- + +## Implementation phases + +Scaffold `koldstore-branching` + `pg_koldstore/src/branching/` first. Prototype **planner DML rewrite** and **same-transaction shared freeze triggers** before overlay GC or merge polish. Those two decide whether the feature is buildable. New logic goes in the branching crate; existing crates only get reuse exports and one-line call sites. + +### Phase 0 — Prerequisites (fail fast) + +- Fix `reconcile_publication_columns` (`DROP`+`ADD` or full-member `SET TABLE`). +- Persist seq watermark on every `__cl` write, independent of `applied_lsn` ack. +- Optional: keep [`pgoutput_old_row_cow.rs`](tests/e2e/dml/pgoutput_old_row_cow.rs) as a WAL-OLD fallback probe. Not on the v1 seed path. + +### Phase 0.5 — Crate scaffold (before feature logic) + +- Add `crates/koldstore-branching` with `//!` crate docs, empty modules listed above, unit tests that compile. +- Add `pg_koldstore/src/branching/mod.rs` and wire `pub mod branching` + workspace deps. +- Update crate-architecture.md in that same change so later phases have a documented home. + +### Phase 1 — Catalog, GUC, activation, status + +- `koldstore.branches` in [`koldstore--0.1.0.sql`](crates/pg_koldstore/sql/koldstore--0.1.0.sql) (beta: edit install SQL, no upgrade edge) and `REQUIRED_CATALOG_TABLES`. +- Types/FSM in `koldstore-branching::catalog`; SPI in `pg_koldstore::branching::catalog`. +- Database flags: `branching_enabled`, `active_branch_count`, `oldest_branch_base_seq`, `flush_postponed_by_branches`. +- `enable_branching()`: drain in-flight flush jobs, refuse cold segments, `CREATE __branch_rows` and `__branch_base`, install freeze triggers (PK-guard pattern), mark enabled. Do **not** set `REPLICA IDENTITY FULL` or change publication column lists. +- Status: extend existing `table_status` / `async_mirror_status`. +- Reject `manage_table` / `unmanage_table` / rewriting DDL / `SET UNLOGGED` / replica-identity changes while any branch is `active`/`merging`. Newly managed tables must be made branch-capable before manage completes if branching is already enabled. + +### Phase 2 — Fail-closed sandbox + +- `ExecutorStart`: unmanaged mutating `ModifyTable` errors when branch ≠ main (ExecutorEnd is too late). +- `ProcessUtility`: `COPY`, `TRUNCATE`, `REFRESH MATERIALIZED VIEW`, CTAS, `CALL` that writes, manage/unmanage. +- Document non-table side effects (`nextval`, large objects). Reject `serial` / identity PK inserts in a branch or treat sequence advance as a declared leak (prefer reject in v1). + +### Phase 3 — Overlay + planner DML rewrite + +- Create `__branch_rows` via `koldstore-branching` overlay planner (reuse `MirrorColumn`, `bounded_identifier`). Exact DDL is in **Per-managed-table artifacts**. +- `manage_table` while branching is enabled creates `__cl` + `__branch_rows` + `__branch_base` in one transaction; `enable_branching()` back-creates both branch heaps for already-managed tables. +- Planner rewrite + tests for INSERT/PK UPDATE/PK DELETE, then `WHERE` against branch view. +- Multi-table txn atomicity = ordinary PostgreSQL txn on overlay heaps. +- Internal merge bypass GUC (`internal_system_write`-style, not user-settable). + +### Phase 4 — Shared freeze + KoldMergeScan front layers + +- AFTER STATEMENT freeze triggers on the source heap; one `__branch_base` insert per mutated PK; `ON CONFLICT (pk, created_xid) DO NOTHING`; gated on open-branch count. +- Fork: arm capture → take `base_snapshot` → fence `base_seq`/`base_lsn` → `active`. +- Refactor `MergeRowStream` Stream path to an ordered `Vec` of sources with **two** entries (hot, cold+`__cl`). Existing merge-scan tests must stay green. Do not change HotChild. +- Attach Overlay/Freeze SPI sources when `current_branch <> main`. Skip `HotProbeState::Pending`/`Delegate` and empty-manifest native return **only** then. Stream list is `[Overlay, Freeze, Hot]` (+ Cold if present). +- Branch portfolio v1: `ExactPrimaryKey` + `UnorderedHotFirst` + `GeneralMerge` (PostgreSQL `Sort` for `ORDER BY`). Do not extend `OrderedProgressive`. +- Exact PK probes overlay → freeze → existing `probe_hot_point_hit`. +- E2E: two branches share freeze rows; in-flight-at-fork still freezes; N branches do not multiply freeze inserts; main `EXPLAIN` HotChild unchanged while a branch is open in another session. + +### Phase 5 — Flush postpone + overlay GC + freeze GC + expiry + +- Gate `flush_table`, `enqueue_flush_job`, scheduler, and **in-flight finalize/prune** on branch admission. +- `discard_branch` / successful `merge_branch` / expiry: catalog closer first (`merged`/`discarded` stop pinning freeze). Async `DELETE FROM __branch_rows WHERE branch_id = $1`. +- Freeze GC in the same closer job (§6): if `active_branch_count = 0` then `TRUNCATE __branch_base` on every managed table; else `DELETE` rows no remaining `creating`/`active`/`merging` snapshot still needs. Never GC inside the main DML trigger. +- `disable_branching()`: refuse while pins remain, or require explicit discard-all then truncate. +- `expires_at` mandatory enough to stop unbounded freeze pin; quota/error before filling the volume. Status exposes freeze-row counts. + +### Phase 6 — Diff and merge + +- `branch_diff` / `branch_conflicts` enumerate overlay PKs only. +- Merge: lock branch exclusive, `ShareRowExclusiveLock` tables in `table_oid` order, fence **before** those locks, re-validate under lock, one heap txn, `fail_on_conflict` applies nothing. +- Do not hold AccessExclusive across a decode fence. +- Cap merge size or add mid-transaction apply checkpoints so a huge merge cannot livelock the applier. + +### Phase 7 — `changes_since` branch argument + PRE_COMMIT overlay seq + +- Main path untouched when `branch = main`. +- Overlay seq + E2E isolation tests above. + +### Phase 8 — Docs, observability, crash tests + +- Keep [`docs/architecture/data-branches.md`](docs/architecture/data-branches.md) in lockstep with behavior (not a late dump). +- Crash: activation, overlay DML, freeze replay (`ON CONFLICT DO NOTHING`), merge, discard. +- Prepared-statement branch switch; parallel workers; RLS/privileges on reconstructed rows (invoker, not definer leak). + +--- + +## Highest-risk files + +New / primary: + +- [`crates/koldstore-branching/`](crates/koldstore-branching) — all branch domain logic +- [`crates/pg_koldstore/src/branching/`](crates/pg_koldstore/src/branching) — SPI, hooks, `#[pg_extern]` + +Existing (small call sites or shared capture fixes only): + +- [`crates/pg_koldstore/src/mirror/lifecycle.rs`](crates/pg_koldstore/src/mirror/lifecycle.rs) — publication membership (SET TABLE bug + no-column-list activation) +- [`crates/pg_koldstore/src/mirror/apply.rs`](crates/pg_koldstore/src/mirror/apply.rs) — watermark only; no freeze from WAL +- [`crates/koldstore-wal-mirror/src/mirror/guard.rs`](crates/koldstore-wal-mirror/src/mirror/guard.rs) — pattern to copy for freeze triggers (do not duplicate naming/hash) +- [`crates/koldstore-wal-mirror/src/mirror/shared/relation.rs`](crates/koldstore-wal-mirror/src/mirror/shared/relation.rs) — export `bounded_identifier` +- [`crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs`](crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs) / [`apply_row.rs`](crates/koldstore-wal-mirror/src/mirror/async/apply_row.rs) — toast fill, byte budget +- [`crates/pg_koldstore/src/hooks/mod.rs`](crates/pg_koldstore/src/hooks/mod.rs) — register `branching::hooks` +- [`crates/koldstore-merge/src/core/resolver.rs`](crates/koldstore-merge/src/core/resolver.rs) — reuse first-seen PK; do not add branch types; do not change Hot/Cold seq winner for main +- [`crates/pg_koldstore/src/merge_scan/pg.rs`](crates/pg_koldstore/src/merge_scan/pg.rs) / [`execute.rs`](crates/pg_koldstore/src/merge_scan/pg/execute.rs) — attach overlay+freeze layers; HotChild only when session is main +- [`crates/pg_koldstore/src/sql/events/mod.rs`](crates/pg_koldstore/src/sql/events/mod.rs) — dispatch `branch => main` vs overlay +- [`crates/pg_koldstore/src/sql/flush/`](crates/pg_koldstore/src/sql/flush/) — call `branching::flush` admission +- [`crates/pg_koldstore/sql/koldstore--0.1.0.sql`](crates/pg_koldstore/sql/koldstore--0.1.0.sql) + +--- + +## v1 non-goals (unchanged, plus gates) + +Schema/nested branches, physical clones, audit history, rebase, column-level merge, `main_wins`/`branch_wins`, branch-aware flush, approximating FK/locks, branching over existing cold data, `REPLICA IDENTITY FULL` / full-row publication for seeds. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 9e751862..5babc62d 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -9,6 +9,8 @@ env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" PG_VER: "16" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: benchmarks: @@ -104,6 +106,9 @@ jobs: env: DATABASE_URL: "host=/var/run/postgresql user=runner dbname=runner" KOLDSTORE_BENCH_START_PGRX: "0" + # Packaged Postgres runs as OS user `postgres` and cannot write under + # $GITHUB_WORKSPACE; keep cold Parquet in /tmp (world-writable). + KOLDSTORE_BENCH_STORAGE_ROOT: /tmp/koldstore-bench-cold # Enable Criterion extension microbenches (default in run.sh skips them). KOLDSTORE_BENCH_SKIP_CRITERION: "0" BENCH_ROWS: "100000" diff --git a/.github/workflows/chat-penetration.yml b/.github/workflows/chat-penetration.yml index 0a234be1..b6763035 100644 --- a/.github/workflows/chat-penetration.yml +++ b/.github/workflows/chat-penetration.yml @@ -45,6 +45,8 @@ env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" PG_VER: "16" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: chat-penetration: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7022995..ec5c8ab1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ on: env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" + # rust-toolchain.toml is nightly for local Cranelift; CI must not follow it. + RUSTUP_TOOLCHAIN: "1.96.0" + # Ignore repo `.cargo/config.toml` `-Z threads=8` (nightly-only). + CARGO_ENCODED_RUSTFLAGS: "" # Needed so EnricoMi can publish check runs + PR comments from JUnit. permissions: @@ -223,6 +227,8 @@ jobs: steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.96.0 + with: + components: clippy - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@v2 with: diff --git a/.github/workflows/deep-hammerdb.yml b/.github/workflows/deep-hammerdb.yml index bd02ff27..4ba3f316 100644 --- a/.github/workflows/deep-hammerdb.yml +++ b/.github/workflows/deep-hammerdb.yml @@ -60,6 +60,8 @@ permissions: env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: deep-hammerdb: diff --git a/.github/workflows/nightly-readiness.yml b/.github/workflows/nightly-readiness.yml index fbb49312..fdc7ec3f 100644 --- a/.github/workflows/nightly-readiness.yml +++ b/.github/workflows/nightly-readiness.yml @@ -9,6 +9,8 @@ env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" KOLDSTORE_SQLSMITH_SECONDS: "30" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: nightly: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4429cb88..080634cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,12 @@ on: required: false default: true docker_push: - description: "Build, smoke-test, and push PostgreSQL 16 + koldstore Docker image to Docker Hub and GHCR (GitHub Packages)" + description: "Build/push Docker images: PG18 (amd64+arm64, latest) + PG16 (amd64). Optionally enable PG17 below." + type: boolean + required: false + default: false + docker_push_pg17: + description: "Also build/push PostgreSQL 17 Docker image (amd64 only). Requires docker_push." type: boolean required: false default: false @@ -42,9 +47,12 @@ env: CARGO_TERM_COLOR: always RUST_VERSION: "1.96.0" PGRX_VERSION: "0.19.1" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" CARGO_PROFILE: release-pg-dist - DOCKER_PG_MAJOR: "16" DOCKER_PG_DISTRO: "ubuntu24.04" + # Try-it image majors published when docker_push=true. `latest` tracks PG18. + DOCKER_LATEST_PG_MAJOR: "18" jobs: read_version: @@ -86,6 +94,12 @@ jobs: cache-on-failure: true - run: cargo install cargo-nextest --locked + - run: cargo install cargo-about --version 0.8.4 --locked + - name: Verify third-party license notices + run: | + bash scripts/build/generate-third-party-notices.sh + git diff --exit-code -- THIRD_PARTY_NOTICES.html + bash scripts/tests/test_release_licenses.sh - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets --no-default-features -- -D warnings - run: cargo nextest run --workspace --no-default-features --exclude e2e --exclude examples --exclude storage-comparison --exclude pg-koldstore-benchmarks --exclude koldstore-memory-tests --exclude stress @@ -200,6 +214,7 @@ jobs: - { pg: 15, arch: arm64, distro: ubuntu22.04, runner: ubuntu-22.04-arm } - { pg: 16, arch: amd64, distro: ubuntu24.04, runner: ubuntu-24.04 } - { pg: 16, arch: arm64, distro: ubuntu24.04, runner: ubuntu-24.04-arm } + - { pg: 17, arch: amd64, distro: ubuntu24.04, runner: ubuntu-24.04 } - { pg: 18, arch: amd64, distro: ubuntu24.04, runner: ubuntu-24.04 } - { pg: 18, arch: arm64, distro: ubuntu24.04, runner: ubuntu-24.04-arm } steps: @@ -537,32 +552,99 @@ jobs: dist/${{ needs.read_version.outputs.version }}/SHA256SUMS fail_on_unmatched_files: true - # Publish PostgreSQL 16 + prebuilt koldstore + pg_cron to Docker Hub and GHCR. - # Per-arch images use the matching ubuntu24.04 package artifact (native .so); - # docker_pg_manifest then publishes a multi-arch :version / :latest index. + # Publish try-it images (prebuilt koldstore + pg_cron) to Docker Hub and GHCR. + # Default when docker_push=true: + # PG18 → amd64 + arm64 (also :latest) + # PG16 → amd64 only + # Optional docker_push_pg17=true: + # PG17 → amd64 only + docker_matrix: + name: Plan Docker image matrix + runs-on: ubuntu-24.04 + if: ${{ inputs.docker_push }} + outputs: + build: ${{ steps.set.outputs.build }} + promote: ${{ steps.set.outputs.promote }} + steps: + - name: Build matrix JSON + id: set + shell: bash + run: | + set -euo pipefail + INCLUDE_PG17="${{ inputs.docker_push_pg17 }}" + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + include_pg17 = os.environ.get("INCLUDE_PG17", "false").lower() == "true" + + build = [ + { + "pg_major": 16, + "arch": "amd64", + "platform": "linux/amd64", + "runner": "ubuntu-24.04", + }, + { + "pg_major": 18, + "arch": "amd64", + "platform": "linux/amd64", + "runner": "ubuntu-24.04", + }, + { + "pg_major": 18, + "arch": "arm64", + "platform": "linux/arm64", + "runner": "ubuntu-24.04-arm", + }, + ] + promote = [ + {"pg_major": 16, "promote_latest": False, "arches": "amd64"}, + {"pg_major": 18, "promote_latest": True, "arches": "amd64 arm64"}, + ] + if include_pg17: + build.append( + { + "pg_major": 17, + "arch": "amd64", + "platform": "linux/amd64", + "runner": "ubuntu-24.04", + } + ) + promote.append( + {"pg_major": 17, "promote_latest": False, "arches": "amd64"} + ) + + print(f"build={json.dumps(build, separators=(',', ':'))}") + print(f"promote={json.dumps(promote, separators=(',', ':'))}") + PY + env: + INCLUDE_PG17: ${{ inputs.docker_push_pg17 }} + docker_pg: - name: "Build PG Docker · ${{ matrix.arch }}" + name: "Build PG${{ matrix.pg_major }} Docker · ${{ matrix.arch }}" runs-on: ${{ matrix.runner }} needs: - read_version - build_linux_ubuntu + - docker_matrix if: >- ${{ inputs.docker_push && always() && !cancelled() && !failure() && - needs.build_linux_ubuntu.result == 'success' + needs.build_linux_ubuntu.result == 'success' && + needs.docker_matrix.result == 'success' }} strategy: fail-fast: false matrix: - include: - - { arch: amd64, platform: linux/amd64, runner: ubuntu-24.04 } - - { arch: arm64, platform: linux/arm64, runner: ubuntu-24.04-arm } + include: ${{ fromJSON(needs.docker_matrix.outputs.build) }} env: DOCKER_REPO_INPUT: ${{ inputs.docker_repo }} GHCR_IMAGE_INPUT: ${{ inputs.ghcr_image }} + DOCKER_PG_MAJOR: ${{ matrix.pg_major }} steps: - uses: actions/checkout@v7 @@ -590,6 +672,7 @@ jobs: VERSION="${{ needs.read_version.outputs.version }}" SHORT_SHA="${GITHUB_SHA:0:7}" ARCH="${{ matrix.arch }}" + PG_MAJOR="${DOCKER_PG_MAJOR}" DOCKER_REPO="${DOCKER_REPO_INPUT:-jamals86/pg-koldstore}" GHCR_IMAGE="${GHCR_IMAGE_INPUT:-ghcr.io/kalamdb/pg-koldstore}" if [[ ! "${DOCKER_REPO}" =~ ^[A-Za-z0-9]+([._-][A-Za-z0-9]+)*\/[A-Za-z0-9]+([._-][A-Za-z0-9]+)*$ ]]; then @@ -600,27 +683,27 @@ jobs: echo "::error::Invalid ghcr_image '${GHCR_IMAGE}'. Expected ghcr.io/owner/name." >&2 exit 1 fi - CANDIDATE_TAG="${VERSION}-pg${DOCKER_PG_MAJOR}-ci-${SHORT_SHA}" + CANDIDATE_TAG="${VERSION}-pg${PG_MAJOR}-ci-${SHORT_SHA}" # OCI image label + Docker Hub short description (≤100 chars). # Full Hub Overview is synced from docker/image-description.txt after promote. - DESCRIPTION="PostgreSQL 16 with koldstore preinstalled for trying KoldStore quickly." + DESCRIPTION="PostgreSQL ${PG_MAJOR} with koldstore preinstalled for trying KoldStore quickly." { echo "docker_repo=${DOCKER_REPO}" echo "ghcr_image=${GHCR_IMAGE}" echo "candidate_tag=${CANDIDATE_TAG}" echo "candidate_arch_tag=${CANDIDATE_TAG}-${ARCH}" - echo "version_tag=${VERSION}-pg${DOCKER_PG_MAJOR}" - echo "latest_tag=latest" + echo "version_tag=${VERSION}-pg${PG_MAJOR}" + echo "pg_major_tag=pg${PG_MAJOR}" echo "description=${DESCRIPTION}" } >> "$GITHUB_OUTPUT" echo "Docker Hub repo: ${DOCKER_REPO}" echo "GHCR image: ${GHCR_IMAGE}" echo "Candidate arch tag: ${CANDIDATE_TAG}-${ARCH} (${{ matrix.platform }})" - - name: Download PG${{ env.DOCKER_PG_MAJOR }} ${{ matrix.arch }} package artifact + - name: Download PG${{ matrix.pg_major }} ${{ matrix.arch }} package artifact uses: actions/download-artifact@v8 with: - name: pkg-pg${{ env.DOCKER_PG_MAJOR }}-${{ env.DOCKER_PG_DISTRO }}-${{ matrix.arch }} + name: pkg-pg${{ matrix.pg_major }}-${{ env.DOCKER_PG_DISTRO }}-${{ matrix.arch }} path: pkg-download/ - name: Prepare docker-artifacts from release tarball @@ -629,9 +712,10 @@ jobs: set -euo pipefail VERSION="${{ needs.read_version.outputs.version }}" ARCH="${{ matrix.arch }}" - TARBALL="$(find pkg-download -type f -name "pg_koldstore-v${VERSION}-pg${DOCKER_PG_MAJOR}-${DOCKER_PG_DISTRO}-${ARCH}.tar.gz" | head -n 1)" + PG_MAJOR="${DOCKER_PG_MAJOR}" + TARBALL="$(find pkg-download -type f -name "pg_koldstore-v${VERSION}-pg${PG_MAJOR}-${DOCKER_PG_DISTRO}-${ARCH}.tar.gz" | head -n 1)" if [[ -z "${TARBALL}" ]]; then - echo "::error::Expected tarball pg_koldstore-v${VERSION}-pg${DOCKER_PG_MAJOR}-${DOCKER_PG_DISTRO}-${ARCH}.tar.gz not found." >&2 + echo "::error::Expected tarball pg_koldstore-v${VERSION}-pg${PG_MAJOR}-${DOCKER_PG_DISTRO}-${ARCH}.tar.gz not found." >&2 find pkg-download -type f | sort >&2 || true exit 1 fi @@ -641,17 +725,23 @@ jobs: SO="$(find tarball-extract -type f -name 'koldstore.so' | head -n 1)" CONTROL="$(find tarball-extract -type f -name 'koldstore.control' | head -n 1)" - if [[ -z "${SO}" || -z "${CONTROL}" ]]; then - echo "::error::koldstore.so / koldstore.control missing from tarball." >&2 + LICENSE_FILE="$(find tarball-extract -type f -name LICENSE | head -n 1)" + NOTICE_FILE="$(find tarball-extract -type f -name NOTICE | head -n 1)" + NOTICES="$(find tarball-extract -type f -name THIRD_PARTY_NOTICES.html | head -n 1)" + if [[ -z "${SO}" || -z "${CONTROL}" || -z "${LICENSE_FILE}" || -z "${NOTICE_FILE}" || -z "${NOTICES}" ]]; then + echo "::error::Release tarball is missing an extension artifact or required license notice." >&2 find tarball-extract -type f | sort >&2 || true exit 1 fi cp "${SO}" docker-artifacts/koldstore.so cp "${CONTROL}" docker-artifacts/koldstore.control + cp "${LICENSE_FILE}" docker-artifacts/LICENSE + cp "${NOTICE_FILE}" docker-artifacts/NOTICE + cp "${NOTICES}" docker-artifacts/THIRD_PARTY_NOTICES.html find "$(dirname "${CONTROL}")" -type f -name 'koldstore--*.sql' -exec cp {} docker-artifacts/ \; - echo "=== docker-artifacts (${ARCH}) ===" + echo "=== docker-artifacts (pg${PG_MAJOR}/${ARCH}) ===" ls -lh docker-artifacts/ file docker-artifacts/koldstore.so || true @@ -680,7 +770,7 @@ jobs: platforms: ${{ matrix.platform }} provenance: false build-args: | - PG_MAJOR=${{ env.DOCKER_PG_MAJOR }} + PG_MAJOR=${{ matrix.pg_major }} OCI_IMAGE_DESCRIPTION=${{ steps.vars.outputs.description }} labels: | org.opencontainers.image.title=pg-koldstore @@ -700,21 +790,22 @@ jobs: IMAGE="${{ steps.vars.outputs.docker_repo }}:${{ steps.vars.outputs.candidate_arch_tag }}" docker pull --platform "${{ matrix.platform }}" "${IMAGE}" chmod +x docker/test-release-image.sh - ./docker/test-release-image.sh "${IMAGE}" 2>&1 | tee "pg-docker-smoke-${{ matrix.arch }}.txt" + ./docker/test-release-image.sh "${IMAGE}" 2>&1 | tee "pg-docker-smoke-pg${{ matrix.pg_major }}-${{ matrix.arch }}.txt" - name: Upload Docker smoke-test log if: always() uses: actions/upload-artifact@v7 with: - name: pg-docker-smoke-${{ matrix.arch }} - path: pg-docker-smoke-${{ matrix.arch }}.txt + name: pg-docker-smoke-pg${{ matrix.pg_major }}-${{ matrix.arch }} + path: pg-docker-smoke-pg${{ matrix.pg_major }}-${{ matrix.arch }}.txt if-no-files-found: ignore docker_pg_manifest: - name: Promote multi-arch PG Docker image + name: "Promote PG${{ matrix.pg_major }} Docker image" runs-on: ubuntu-24.04 needs: - read_version + - docker_matrix - docker_pg if: >- ${{ @@ -722,11 +813,17 @@ jobs: always() && !cancelled() && !failure() && + needs.docker_matrix.result == 'success' && needs.docker_pg.result == 'success' }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.docker_matrix.outputs.promote) }} env: DOCKER_REPO_INPUT: ${{ inputs.docker_repo }} GHCR_IMAGE_INPUT: ${{ inputs.ghcr_image }} + DOCKER_PG_MAJOR: ${{ matrix.pg_major }} steps: - uses: actions/checkout@v7 @@ -737,16 +834,20 @@ jobs: set -euo pipefail VERSION="${{ needs.read_version.outputs.version }}" SHORT_SHA="${GITHUB_SHA:0:7}" + PG_MAJOR="${DOCKER_PG_MAJOR}" DOCKER_REPO="${DOCKER_REPO_INPUT:-jamals86/pg-koldstore}" GHCR_IMAGE="${GHCR_IMAGE_INPUT:-ghcr.io/kalamdb/pg-koldstore}" - CANDIDATE_TAG="${VERSION}-pg${DOCKER_PG_MAJOR}-ci-${SHORT_SHA}" - DESCRIPTION="PostgreSQL 16 with koldstore preinstalled for trying KoldStore quickly." + CANDIDATE_TAG="${VERSION}-pg${PG_MAJOR}-ci-${SHORT_SHA}" + DESCRIPTION="PostgreSQL ${PG_MAJOR} with koldstore preinstalled for trying KoldStore quickly." { echo "docker_repo=${DOCKER_REPO}" echo "ghcr_image=${GHCR_IMAGE}" echo "candidate_tag=${CANDIDATE_TAG}" - echo "version_tag=${VERSION}-pg${DOCKER_PG_MAJOR}" + echo "version_tag=${VERSION}-pg${PG_MAJOR}" + echo "pg_major_tag=pg${PG_MAJOR}" echo "latest_tag=latest" + echo "promote_latest=${{ matrix.promote_latest }}" + echo "arches=${{ matrix.arches }}" echo "description=${DESCRIPTION}" } >> "$GITHUB_OUTPUT" @@ -766,7 +867,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Promote validated multi-arch tags + - name: Promote validated tags shell: bash run: | set -euo pipefail @@ -774,28 +875,56 @@ jobs: GHCR_IMAGE="${{ steps.vars.outputs.ghcr_image }}" CANDIDATE="${{ steps.vars.outputs.candidate_tag }}" VERSION_TAG="${{ steps.vars.outputs.version_tag }}" + PG_MAJOR_TAG="${{ steps.vars.outputs.pg_major_tag }}" LATEST_TAG="${{ steps.vars.outputs.latest_tag }}" + PROMOTE_LATEST="${{ steps.vars.outputs.promote_latest }}" + ARCHES="${{ steps.vars.outputs.arches }}" + + hub_tags=( + -t "${HUB_REPO}:${CANDIDATE}" + -t "${HUB_REPO}:${VERSION_TAG}" + -t "${HUB_REPO}:${PG_MAJOR_TAG}" + ) + ghcr_tags=( + -t "${GHCR_IMAGE}:${CANDIDATE}" + -t "${GHCR_IMAGE}:${VERSION_TAG}" + -t "${GHCR_IMAGE}:${PG_MAJOR_TAG}" + ) + if [[ "${PROMOTE_LATEST}" == "true" ]]; then + hub_tags+=(-t "${HUB_REPO}:${LATEST_TAG}") + ghcr_tags+=(-t "${GHCR_IMAGE}:${LATEST_TAG}") + fi + + hub_sources=() + ghcr_sources=() + for arch in ${ARCHES}; do + hub_sources+=("${HUB_REPO}:${CANDIDATE}-${arch}") + ghcr_sources+=("${GHCR_IMAGE}:${CANDIDATE}-${arch}") + done - # Single-arch candidates from docker_pg → multi-arch indexes. + # Per-arch candidates from docker_pg → published indexes (1+ platforms). docker buildx imagetools create \ - -t "${HUB_REPO}:${CANDIDATE}" \ - -t "${HUB_REPO}:${VERSION_TAG}" \ - -t "${HUB_REPO}:${LATEST_TAG}" \ - "${HUB_REPO}:${CANDIDATE}-amd64" \ - "${HUB_REPO}:${CANDIDATE}-arm64" + "${hub_tags[@]}" \ + "${hub_sources[@]}" docker buildx imagetools create \ - -t "${GHCR_IMAGE}:${CANDIDATE}" \ - -t "${GHCR_IMAGE}:${VERSION_TAG}" \ - -t "${GHCR_IMAGE}:${LATEST_TAG}" \ - "${GHCR_IMAGE}:${CANDIDATE}-amd64" \ - "${GHCR_IMAGE}:${CANDIDATE}-arm64" + "${ghcr_tags[@]}" \ + "${ghcr_sources[@]}" - echo "::notice::Published multi-arch ${HUB_REPO}:{${CANDIDATE},${VERSION_TAG},${LATEST_TAG}}" - echo "::notice::Published multi-arch ${GHCR_IMAGE}:{${CANDIDATE},${VERSION_TAG},${LATEST_TAG}}" - docker buildx imagetools inspect "${HUB_REPO}:${LATEST_TAG}" + published="${CANDIDATE},${VERSION_TAG},${PG_MAJOR_TAG}" + if [[ "${PROMOTE_LATEST}" == "true" ]]; then + published+=",${LATEST_TAG}" + fi + echo "::notice::Published ${HUB_REPO}:{${published}} from arches [${ARCHES}]" + echo "::notice::Published ${GHCR_IMAGE}:{${published}} from arches [${ARCHES}]" + if [[ "${PROMOTE_LATEST}" == "true" ]]; then + docker buildx imagetools inspect "${HUB_REPO}:${LATEST_TAG}" + else + docker buildx imagetools inspect "${HUB_REPO}:${VERSION_TAG}" + fi - name: Update Docker Hub repository overview + if: matrix.promote_latest uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/upstream-pg-regress.yml b/.github/workflows/upstream-pg-regress.yml index a2f30911..ae58aeaf 100644 --- a/.github/workflows/upstream-pg-regress.yml +++ b/.github/workflows/upstream-pg-regress.yml @@ -10,6 +10,8 @@ env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" KOLDSTORE_UPSTREAM_REGRESS_EXECUTE: "0" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: upstream-signal: diff --git a/.github/workflows/weekly-hammerdb.yml b/.github/workflows/weekly-hammerdb.yml index c4713761..70170720 100644 --- a/.github/workflows/weekly-hammerdb.yml +++ b/.github/workflows/weekly-hammerdb.yml @@ -11,6 +11,8 @@ env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" PG_VER: "16" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" KOLDSTORE_HAMMERDB_MINUTES: "2" KOLDSTORE_HAMMERDB_WAREHOUSES: "2" KOLDSTORE_HAMMERDB_VU: "2" diff --git a/.github/workflows/weekly-long-tests.yml b/.github/workflows/weekly-long-tests.yml index f9d3a353..20b40f31 100644 --- a/.github/workflows/weekly-long-tests.yml +++ b/.github/workflows/weekly-long-tests.yml @@ -52,6 +52,8 @@ on: env: CARGO_TERM_COLOR: always PGRX_VERSION: "0.19.1" + RUSTUP_TOOLCHAIN: "1.96.0" + CARGO_ENCODED_RUSTFLAGS: "" jobs: resolve-matrix: diff --git a/AGENTS.md b/AGENTS.md index 56893109..8793d760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,98 +1,58 @@ -# Agent Guidance +# Engineering Guidance -## Uncommitted Work Follows the Active Branch +## Working Tree and Git -- Never leave uncommitted changes parked on a previous branch when starting or - switching to other work. -- If the session moves to a newer/target branch (or the user asks to continue - elsewhere) and the working tree still has local edits, **move those changes - onto the branch that will continue the work** before doing anything else — - typically `git stash push -u`, checkout/switch, then `git stash pop` (or an - equivalent carry-over). Resolve conflicts on the destination branch. -- Do not commit solely to “park” WIP unless the user explicitly asks for a - commit. Prefer stash/carry-over so history stays clean. -- Before ending a turn that switched branches, confirm with `git status` that - no WIP remains stranded on the old branch tip. +- Keep WIP on the branch that continues the work. Before switching branches, + stash/carry uncommitted changes; do not leave edits on an old branch. +- Do not commit merely to park WIP. Before ending after a branch switch, confirm + with `git status` that no work was stranded. -## Catalog SQL During Development +## Local compile -- Edit `crates/pg_koldstore/sql/koldstore--0.1.0.sql` directly for catalog DDL. -- Do **not** add `koldstore----.sql` upgrade edges while the product - is still in development/beta. Local installs reinstall or resync when the - bootstrap fragment changes. -- Introduce packaged `ALTER EXTENSION … UPDATE` edges only when intentionally - shipping a supported upgrade path. - -## Formatting Is Required Before Done - -- After editing Rust, run `cargo fmt --all` (or at least format the touched - files) before ending the turn. Do not leave `cargo fmt --all -- --check` - diffs for the user to clean up. -- Treat rustfmt output as authoritative: apply the suggested layout (line - breaks, chaining, trailing commas, import order) rather than hand-formatting - around it. -- If a pre-commit hook or CI fails on fmt, fix formatting in a follow-up edit - immediately — do not ask the user to run fmt, and do not commit with - `--no-verify` to bypass it. -- Prefer verifying with `cargo fmt --all -- --check` when claiming work is - complete or ready to commit. - -## Testing Loop - -- Keep the default development and verification loop local and fast with pgrx-managed PostgreSQL. -- Tests under `tests/` should target local pgrx workflows, for example `cargo pgrx test`, `cargo pgrx install`, and pgrx-managed Postgres ports. -- Do not make `tests/` depend on Docker or Docker Compose. Docker belongs only to Docker-specific packaging and runtime checks. -- Docker-targeted scripts, Compose files, and image validation should live under `docker/` or clearly Docker-owned paths. -- Treat Docker as a final packaging smoke test, not the main correctness loop. - -## Tests Must Exercise Real Bugs - -- When a test reveals an extension defect, **fix the extension**. Do not weaken the test, rewrite the query to avoid the failing plan, or sort/filter in the test client to hide incorrect scan results. -- Workarounds in tests (`ORDER BY` removed, literals instead of parameters, client-side merge) are only allowed as a temporary bisect step and must be reverted once the product fix lands. -- Prefer adding a focused regression e2e that would have caught the bug (for example ordered `SELECT … LIMIT` after multi-wave flush) before calling the fix done. -- Managed-table reads must never omit hot or cold rows that should be visible, including under load, during flush, and for `ORDER BY` / `LIMIT` / parameterized plans. +Use the default dev profile for check/test/run. No `--release` unless packaging. -## Hot-Only Merge Scan Path Is Locked +## Catalog, Formatting, and Tests -- Treat `crates/pg_koldstore/src/merge_scan/` hot-only emit and plan-time prune - paths as performance-critical and locked: - - plan-time early return when published cold cannot contribute - (`cold_side_proven_empty` / empty manifest → keep native heap paths) - - `EmitPath::HotChild` / `ScanEmitMode::HotChild` delegation - - hot point-hit probe before Parquet open - - catalog/cache short-circuits for absent cold segments -- Do **not** casually rewrite, “simplify,” or regress that path while fixing - tests, EXPLAIN formatting, or unrelated scan features. -- Prefer updating tests/docs to the current contract: - - cold-capable predicates: `KoldMergeScan` with `Hot Scan` + `Planned Access` - + `Actual Access` (`Native PostgreSQL Child` for - `OrderedProgressive` / `UnorderedHotFirst`; `SPI JSON Keyset Scan` only for - `GeneralMerge` / `merge_stream`) - - cold-proven-empty hot PK lookups: native Index/Seq/Bitmap Scan with **no** - `KoldMergeScan` wrapper (plan-time early return; not a portfolio strategy) -- Any intentional change requires an explicit user request, a clear - performance/correctness rationale, and verification that hot-only PK lookups - remain fast and that hot+cold merge correctness is unchanged. - -## Rust Design Preferences - -- Prefer type-safe domain objects for identifiers, sequence values, table names, primary keys, and related boundaries, such as `SeqId`-style newtypes instead of raw integers or strings. -- Keep objects lightweight and explicit. Avoid broad stringly-typed APIs when a focused type or enum captures the invariant. -- Split large files by feature or responsibility when they become hard to scan. -- Split crates only when there is a clear ownership, dependency, testing, or reuse boundary. -- Favor small, composable modules over large catch-all modules. - -## Crate Architecture - -- Follow the layered crate layout in `docs/architecture/crate-architecture.md`. -- `koldstore-common` is the only crate with no internal `koldstore-*` dependencies. -- `pgrx` belongs only in `pg_koldstore`. Library crates must stay PostgreSQL-free. -- New domain logic goes in the lowest crate that does not need SPI, hooks, or OIDs. -- When moving code, remove dead helpers and duplicate types; do not carry unused code. - -## Documentation Standard - -- Every crate `lib.rs` and module file starts with a `//!` header describing ownership and purpose. -- Logic-bearing public functions need `///` docs with purpose, invariants, and `# Errors` where applicable. -- Extension `#[pg_extern]` wrappers document the SQL contract and which library crate they delegate to. -- Comments explain intent, not restate the code. +- Edit `crates/pg_koldstore/sql/koldstore--0.1.0.sql` directly for catalog DDL. + Do not add extension upgrade edges during development/beta; add them only for + an intentionally supported upgrade path. +- After Rust edits, run `cargo fmt --all`; before handoff, verify with + `cargo fmt --all -- --check`. +- Use local pgrx PostgreSQL for the normal test loop. Tests under `tests/` must + not require Docker; Docker is a packaging smoke test only. +- A regression test must expose the real extension behavior. Fix the extension, + not the test, and never hide missing hot or cold rows with client-side logic. + +## Managed Reads and Merge Scan + +- Keep hot-only paths fast: an empty manifest or a cold-proven-empty predicate + keeps native heap paths; `EmitPath::HotChild` delegates; a hot PK hit occurs + before a Parquet open. +- Do not rewrite or regress these paths for unrelated work. Any intentional + change needs an explicit request, a performance/correctness rationale, and + verification of both hot-only speed and hot+cold correctness. +- A query that cold storage can satisfy must use the merge path. `ORDER BY`, + `LIMIT`, joins, parameters, flush, and concurrent DML must not hide visible + hot or cold rows. + +## Rust and Crate Boundaries + +- Prefer small, type-safe domain types over stringly APIs; split large modules + by responsibility and remove dead helpers when moving code. +- Follow [crate architecture](docs/architecture/crate-architecture.md): + `koldstore-common` has no internal dependencies, `pgrx` stays in + `pg_koldstore`, and domain logic belongs in the lowest layer that does not + need PostgreSQL hooks, SPI, or OIDs. + +## Documentation + +- Update the relevant files in `docs/architecture/` in the same change whenever + behavior or the operational contract materially changes: management or + unmanagement, WAL/mirror capture, flush or cold storage, merge-scan selection + or correctness, catalog/jobs, or table/schema/database DDL behavior. +- Do not add architecture-doc churn for a purely internal refactor. Document + the user-visible behavior, invariants, and why the design is constrained; keep + examples and plan/EXPLAIN contracts aligned with the code. +- Every crate `lib.rs` and module file starts with `//!`. Public logic-bearing + functions document purpose, invariants, and `# Errors`; `#[pg_extern]` + wrappers document their SQL contract and delegated library behavior. diff --git a/Cargo.lock b/Cargo.lock index d16d3b3c..4e089ebb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,7 +704,7 @@ dependencies = [ [[package]] name = "e2e" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "anyhow", "bytes", @@ -718,6 +718,7 @@ dependencies = [ "koldstore-parquet", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", "parquet", "pg_koldstore", "serde", @@ -779,7 +780,7 @@ dependencies = [ [[package]] name = "examples" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "anyhow", "chrono", @@ -787,6 +788,7 @@ dependencies = [ "koldstore-memory-tests", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", "parquet", "serde", "serde_json", @@ -881,9 +883,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -891,44 +893,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.0", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1429,7 +1431,7 @@ dependencies = [ [[package]] name = "koldstore-catalog" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-common", "koldstore-schema", @@ -1440,7 +1442,7 @@ dependencies = [ [[package]] name = "koldstore-common" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "hex", "serde", @@ -1451,7 +1453,7 @@ dependencies = [ [[package]] name = "koldstore-flush" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "hex", "koldstore-catalog", @@ -1472,7 +1474,7 @@ dependencies = [ [[package]] name = "koldstore-manifest" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "chrono", "hex", @@ -1488,11 +1490,11 @@ dependencies = [ [[package]] name = "koldstore-memory-tests" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" [[package]] name = "koldstore-merge" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-catalog", "koldstore-common", @@ -1507,7 +1509,7 @@ dependencies = [ [[package]] name = "koldstore-migrate" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-catalog", "koldstore-common", @@ -1522,7 +1524,7 @@ dependencies = [ [[package]] name = "koldstore-parquet" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "arrow-array", "arrow-schema", @@ -1542,7 +1544,7 @@ dependencies = [ [[package]] name = "koldstore-schema" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-common", "serde", @@ -1553,11 +1555,11 @@ dependencies = [ [[package]] name = "koldstore-setup" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" [[package]] name = "koldstore-sortkey" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "chrono", "serde_json", @@ -1568,7 +1570,7 @@ dependencies = [ [[package]] name = "koldstore-storage" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "bytes", "futures-util", @@ -1587,7 +1589,7 @@ dependencies = [ [[package]] name = "koldstore-supervisor" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-flush", "koldstore-wal-mirror", @@ -1595,7 +1597,7 @@ dependencies = [ [[package]] name = "koldstore-wal-mirror" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-common", "serde", @@ -1991,7 +1993,7 @@ dependencies = [ [[package]] name = "pg-koldstore-benchmarks" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "anyhow", "chrono", @@ -2015,7 +2017,7 @@ dependencies = [ [[package]] name = "pg_koldstore" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "hex", "koldstore-catalog", @@ -2040,7 +2042,7 @@ dependencies = [ [[package]] name = "pg_koldstore-shell-tests" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "koldstore-catalog", "koldstore-common", @@ -2829,7 +2831,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "storage-comparison" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "anyhow", "chrono", @@ -2837,6 +2839,7 @@ dependencies = [ "koldstore-memory-tests", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", "serde", "serde_json", "tokio", @@ -2854,7 +2857,7 @@ dependencies = [ [[package]] name = "stress" -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" dependencies = [ "anyhow", "chrono", @@ -2862,6 +2865,7 @@ dependencies = [ "koldstore-memory-tests", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", "serde", "serde_json", "tokio", @@ -2972,18 +2976,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -3332,9 +3336,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 9c3af123..ec4562fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ default-members = [ resolver = "2" [workspace.package] -version = "0.1.11-preview.0" +version = "0.1.12-preview.0" edition = "2021" license = "Apache-2.0" repository = "https://github.com/kalamdb/koldstore" @@ -71,7 +71,7 @@ arrow-array = { version = "59.2.0", default-features = false } arrow-schema = { version = "59.2.0", default-features = false } bytes = "1.12.1" chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } -futures-util = "0.3.33" +futures-util = "0.3.34" hex = "0.4.3" # Backend features (`fs`, and optionally cloud via `koldstore-storage`'s # `s3`/`gcs`/`azure` features: *-base + rustls-no-provider/ring) stay out of @@ -90,13 +90,13 @@ serde_json = "1.0.151" sha2 = "0.11.0" storekey = { version = "=0.11.0", default-features = false, features = ["uuid"] } tempfile = "3.27.0" -thiserror = "2.0.19" +thiserror = "2.0.20" # Shared runtime features for library crates. Test binaries add `macros`; # benchmarks add `process` where `tokio::process` is used. tokio = { version = "1.53.1", features = ["rt-multi-thread", "time"] } tokio-postgres = "0.7.18" tracing = "0.1.44" -uuid = { version = "1.24.0", features = ["v4", "serde"] } +uuid = { version = "1.26.0", features = ["v4", "serde"] } [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" @@ -107,6 +107,16 @@ suspicious = "warn" complexity = "warn" perf = "warn" +# Faster debug builds: panic backtraces keep file/line info without full DWARF. +# Nightly uses `-Z threads=8` via `.cargo/config.toml`. Optional Cranelift: +# `CARGO_PROFILE_DEV_CODEGEN_BACKEND=cranelift` (not committed; Cargo 1.96 +# rejects that unstable profile key, and CI pins rustc 1.96.0). +[profile.dev] +debug = "line-tables-only" + +[profile.test] +debug = "line-tables-only" + [profile.release] opt-level = 3 lto = "thin" diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..dd294f17 --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +KoldStore +Copyright 2026 KoldStore contributors + +KoldStore is an independent project and is not affiliated with, endorsed by, +or sponsored by the PostgreSQL Project or the PostgreSQL Community Association +of Canada. Postgres and PostgreSQL are registered trademarks of the PostgreSQL +Community Association of Canada. diff --git a/README.md b/README.md index 172fae1c..d1514420 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KoldStore -> **An open research project exploring transparent tiered storage for PostgreSQL application tables.** +> **An open research project exploring a tiered-storage overlay for PostgreSQL application tables.** Keep active rows in PostgreSQL. Move historical rows to compressed Parquet on filesystem or object storage. Continue querying supported hot and cold data through the original table. @@ -110,7 +110,7 @@ KoldStore is organized around concrete research questions rather than only a fea | Manage existing PostgreSQL tables | ✅ Working | | Keep application-visible schemas clean | ✅ Working | | Query supported hot and cold rows through the original table | 🧪 Experimental | -| Native PostgreSQL foreground writes | ✅ Working | +| Native PostgreSQL foreground writes against hot rows | ✅ Working | | Committed-WAL latest-state mirror | ✅ Working, asynchronous | | Manual and automatic flushing | ✅ Working | | Row-limit and age-based lifecycle policies | ✅ Working | @@ -120,7 +120,7 @@ KoldStore is organized around concrete research questions rather than only a fea | Segment and row-group pruning | 🧪 Working baseline | | Latest-state `changes_since` cursor | 🧪 Working baseline | | Durable migration and flush jobs | ✅ Working baseline | -| PostgreSQL 15–18 | ✅ Supported | +| PostgreSQL 15–18 build and experimental runtime matrix | 🧪 Tested | | Standard DML against cold-only rows | ❌ Not supported yet | | Global hot+cold `UNIQUE` and foreign keys | ❌ Not supported | | Compaction | 🚧 In development | @@ -131,7 +131,7 @@ KoldStore is organized around concrete research questions rather than only a fea ## Try it -The preview Docker image includes PostgreSQL 16 with KoldStore preloaded and logical WAL enabled. +The preview Docker image includes PostgreSQL **18** with KoldStore preloaded and logical WAL enabled (`latest`, amd64 + arm64). PostgreSQL 16 stays available as `:pg16` / `:-pg16` (**amd64 only**). PostgreSQL 17 images (`:pg17`) are published only when enabled on the Release workflow. ```bash docker pull jamals86/pg-koldstore:latest @@ -143,6 +143,13 @@ docker run --rm \ jamals86/pg-koldstore:latest ``` +PostgreSQL 16: + +```bash +docker pull jamals86/pg-koldstore:pg16 +docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 jamals86/pg-koldstore:pg16 +``` + Connect: ```bash @@ -163,7 +170,7 @@ SELECT koldstore.register_storage( ); ``` -Create a normal PostgreSQL table: +Create an ordinary PostgreSQL heap table: ```sql CREATE TABLE messages ( @@ -289,7 +296,8 @@ Foreground `INSERT`, `UPDATE`, and `DELETE` operate on the PostgreSQL heap. Committed primary-key changes are then applied asynchronously to the latest-state mirror through logical WAL. -Use the explicit consistency fence before work that must observe all committed source changes in the mirror: +Use the explicit consistency fence before work that must observe source changes +that committed before the fence captured its WAL boundary: ```sql SELECT koldstore.wait_for_async_mirror(); @@ -297,8 +305,13 @@ SELECT koldstore.wait_for_async_mirror(); Important boundaries: +- The fence does not expose the caller's uncommitted changes. In + `REPEATABLE READ` or `SERIALIZABLE`, call it before establishing the snapshot + that must include those commits. - Standard DML does not currently mutate a row that exists only in cold storage. - An unfenced update or delete can temporarily leave an older cold version visible. +- PostgreSQL row locks, system columns, native uniqueness checks, and predicate + locking do not extend to cold Parquet rows. - The mirror stores the latest state per primary key; it is not an append-only event history. - Primary-key mutation is not supported for managed tables. - PostgreSQL-native indexes remain attached only to hot rows. @@ -515,4 +528,4 @@ Links: ## License Apache License 2.0. -Copyright 2026 KalamDB. +Copyright 2026 KoldStore contributors. diff --git a/THIRD_PARTY_NOTICES.html b/THIRD_PARTY_NOTICES.html new file mode 100644 index 00000000..ccc3e694 --- /dev/null +++ b/THIRD_PARTY_NOTICES.html @@ -0,0 +1,5433 @@ + + + + + KoldStore third-party notices + + +

KoldStore third-party notices

+

+ This file is generated from the locked Rust dependency graph. It applies to + the KoldStore release artifact that contains this file. +

+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • arrow-array 59.2.0
  • +
  • arrow-buffer 59.2.0
  • +
  • arrow-data 59.2.0
  • +
  • arrow-ipc 59.2.0
  • +
  • arrow-schema 59.2.0
  • +
  • arrow-select 59.2.0
  • +
  • parquet 59.2.0
  • +
  • utf8_iter 1.0.4
  • +
  • zeroize 1.9.0
  • +
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • object_store 0.14.1
  • +
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • zerocopy-derive 0.8.52
  • +
  • zerocopy 0.8.52
  • +
+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2023 The Fuchsia Authors
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • crc-fast 1.10.0
  • +
  • rustls-platform-verifier 0.7.0
  • +
+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • storekey 0.11.0
  • +
+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright © SurrealDB Ltd
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • ipnet 2.12.0
  • +
+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright 2017 Juniper Networks, Inc.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • hex 0.4.3
  • +
  • humantime 2.4.0
  • +
+
                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • futures-channel 0.3.33
  • +
  • futures-core 0.3.33
  • +
  • futures-io 0.3.33
  • +
  • futures-macro 0.3.33
  • +
  • futures-sink 0.3.33
  • +
  • futures-task 0.3.33
  • +
  • futures-util 0.3.33
  • +
  • futures 0.3.32
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright (c) 2016 Alex Crichton
+Copyright (c) 2017 The Tokio Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • typenum 1.20.1
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2014 Paho Lurie-Gregg
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • reqwest 0.13.4
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2016 Sean McArthur
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • http 1.4.2
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2017 http-rs authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • tokio-rustls 0.26.4
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2017 quininer kel
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • iana-time-zone 0.1.65
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2020 Andrew Straw
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • rustls-pki-types 1.15.0
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright 2023 Dirkjan Ochtman
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • ahash 0.8.12
  • +
  • atomic-waker 1.1.2
  • +
  • base64 0.22.1
  • +
  • base64 0.23.1
  • +
  • bitflags 2.13.0
  • +
  • cfg-if 1.0.4
  • +
  • core-foundation-sys 0.8.7
  • +
  • core-foundation 0.10.1
  • +
  • displaydoc 0.2.6
  • +
  • either 1.16.0
  • +
  • equivalent 1.0.2
  • +
  • eyre 0.6.12
  • +
  • fixedbitset 0.5.7
  • +
  • fnv 1.0.7
  • +
  • form_urlencoded 1.2.2
  • +
  • hashbrown 0.15.5
  • +
  • hashbrown 0.17.1
  • +
  • httparse 1.10.1
  • +
  • hyper-rustls 0.27.9
  • +
  • idna 1.1.0
  • +
  • idna_adapter 1.2.2
  • +
  • indexmap 2.14.0
  • +
  • itertools 0.15.0
  • +
  • lock_api 0.4.14
  • +
  • log 0.4.33
  • +
  • num-bigint 0.5.1
  • +
  • num-complex 0.4.6
  • +
  • num-integer 0.1.46
  • +
  • num-traits 0.2.19
  • +
  • once_cell 1.21.4
  • +
  • openssl-probe 0.2.1
  • +
  • parking_lot 0.12.5
  • +
  • parking_lot_core 0.9.12
  • +
  • percent-encoding 2.3.2
  • +
  • petgraph 0.8.3
  • +
  • ring 0.17.14
  • +
  • rustls-native-certs 0.8.4
  • +
  • rustls 0.23.43
  • +
  • scopeguard 1.2.0
  • +
  • security-framework-sys 2.17.0
  • +
  • security-framework 3.7.0
  • +
  • serde_cbor 0.11.2
  • +
  • smallvec 1.15.2
  • +
  • socket2 0.6.4
  • +
  • stable_deref_trait 1.2.1
  • +
  • unicode-segmentation 1.13.3
  • +
  • url 2.5.8
  • +
  • uuid 1.24.0
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • block-buffer 0.12.1
  • +
  • chacha20 0.10.1
  • +
  • const-oid 0.10.2
  • +
  • cpufeatures 0.3.0
  • +
  • crypto-common 0.1.7
  • +
  • crypto-common 0.2.2
  • +
  • digest 0.10.7
  • +
  • digest 0.11.3
  • +
  • hybrid-array 0.4.13
  • +
  • sha2 0.11.0
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • rand_core 0.10.1
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     https://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • getrandom 0.2.17
  • +
  • getrandom 0.3.4
  • +
  • getrandom 0.4.3
  • +
+
                              Apache License
+                        Version 2.0, January 2004
+                     https://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • koldstore-catalog 0.1.12-preview.0
  • +
  • koldstore-common 0.1.12-preview.0
  • +
  • koldstore-flush 0.1.12-preview.0
  • +
  • koldstore-manifest 0.1.12-preview.0
  • +
  • koldstore-merge 0.1.12-preview.0
  • +
  • koldstore-migrate 0.1.12-preview.0
  • +
  • koldstore-parquet 0.1.12-preview.0
  • +
  • koldstore-schema 0.1.12-preview.0
  • +
  • koldstore-sortkey 0.1.12-preview.0
  • +
  • koldstore-storage 0.1.12-preview.0
  • +
  • koldstore-supervisor 0.1.12-preview.0
  • +
  • koldstore-wal-mirror 0.1.12-preview.0
  • +
  • pg_koldstore 0.1.12-preview.0
  • +
  • async-trait 0.1.89
  • +
  • cee-scape 0.2.0
  • +
  • enum-map-derive 0.17.0
  • +
  • enum-map 2.7.3
  • +
  • flatbuffers 25.12.19
  • +
  • half 1.8.3
  • +
  • half 2.7.1
  • +
  • indenter 0.3.4
  • +
  • itoa 1.0.18
  • +
  • libc 0.2.186
  • +
  • pin-project-lite 0.2.17
  • +
  • proc-macro2 1.0.106
  • +
  • quote 1.0.46
  • +
  • rand 0.10.2
  • +
  • ryu 1.0.23
  • +
  • seq-macro 0.3.6
  • +
  • serde 1.0.229
  • +
  • serde_core 1.0.229
  • +
  • serde_derive 1.0.229
  • +
  • serde_json 1.0.151
  • +
  • serde_urlencoded 0.7.1
  • +
  • syn 2.0.118
  • +
  • syn 3.0.0
  • +
  • sync_wrapper 1.0.2
  • +
  • thiserror-impl 2.0.19
  • +
  • thiserror 2.0.19
  • +
  • unicode-ident 1.0.24
  • +
  • zstd-safe 7.2.4
  • +
  • zstd-sys 2.0.16+zstd.1.5.7
  • +
+
Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
+
+"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
+
+     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
+
+     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
+
+     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
+
+     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
+
+     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+
+
+

Apache License 2.0 (Apache-2.0)

+

Used by

+
    +
  • chrono 0.4.45
  • +
+
Rust-chrono is dual-licensed under The MIT License [1] and
+Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and
+contributors.
+
+Nota Bene: This is same as the Rust Project's own license.
+
+
+[1]: <http://opensource.org/licenses/MIT>, which is reproduced below:
+
+~~~~
+The MIT License (MIT)
+
+Copyright (c) 2014, Kang Seonghoon.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+~~~~
+
+
+[2]: <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below:
+
+~~~~
+                              Apache License
+                        Version 2.0, January 2004
+                     http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+   "License" shall mean the terms and conditions for use, reproduction,
+   and distribution as defined by Sections 1 through 9 of this document.
+
+   "Licensor" shall mean the copyright owner or entity authorized by
+   the copyright owner that is granting the License.
+
+   "Legal Entity" shall mean the union of the acting entity and all
+   other entities that control, are controlled by, or are under common
+   control with that entity. For the purposes of this definition,
+   "control" means (i) the power, direct or indirect, to cause the
+   direction or management of such entity, whether by contract or
+   otherwise, or (ii) ownership of fifty percent (50%) or more of the
+   outstanding shares, or (iii) beneficial ownership of such entity.
+
+   "You" (or "Your") shall mean an individual or Legal Entity
+   exercising permissions granted by this License.
+
+   "Source" form shall mean the preferred form for making modifications,
+   including but not limited to software source code, documentation
+   source, and configuration files.
+
+   "Object" form shall mean any form resulting from mechanical
+   transformation or translation of a Source form, including but
+   not limited to compiled object code, generated documentation,
+   and conversions to other media types.
+
+   "Work" shall mean the work of authorship, whether in Source or
+   Object form, made available under the License, as indicated by a
+   copyright notice that is included in or attached to the work
+   (an example is provided in the Appendix below).
+
+   "Derivative Works" shall mean any work, whether in Source or Object
+   form, that is based on (or derived from) the Work and for which the
+   editorial revisions, annotations, elaborations, or other modifications
+   represent, as a whole, an original work of authorship. For the purposes
+   of this License, Derivative Works shall not include works that remain
+   separable from, or merely link (or bind by name) to the interfaces of,
+   the Work and Derivative Works thereof.
+
+   "Contribution" shall mean any work of authorship, including
+   the original version of the Work and any modifications or additions
+   to that Work or Derivative Works thereof, that is intentionally
+   submitted to Licensor for inclusion in the Work by the copyright owner
+   or by an individual or Legal Entity authorized to submit on behalf of
+   the copyright owner. For the purposes of this definition, "submitted"
+   means any form of electronic, verbal, or written communication sent
+   to the Licensor or its representatives, including but not limited to
+   communication on electronic mailing lists, source code control systems,
+   and issue tracking systems that are managed by, or on behalf of, the
+   Licensor for the purpose of discussing and improving the Work, but
+   excluding communication that is conspicuously marked or otherwise
+   designated in writing by the copyright owner as "Not a Contribution."
+
+   "Contributor" shall mean Licensor and any individual or Legal Entity
+   on behalf of whom a Contribution has been received by Licensor and
+   subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   copyright license to reproduce, prepare Derivative Works of,
+   publicly display, publicly perform, sublicense, and distribute the
+   Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+   this License, each Contributor hereby grants to You a perpetual,
+   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+   (except as stated in this section) patent license to make, have made,
+   use, offer to sell, sell, import, and otherwise transfer the Work,
+   where such license applies only to those patent claims licensable
+   by such Contributor that are necessarily infringed by their
+   Contribution(s) alone or by combination of their Contribution(s)
+   with the Work to which such Contribution(s) was submitted. If You
+   institute patent litigation against any entity (including a
+   cross-claim or counterclaim in a lawsuit) alleging that the Work
+   or a Contribution incorporated within the Work constitutes direct
+   or contributory patent infringement, then any patent licenses
+   granted to You under this License for that Work shall terminate
+   as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+   Work or Derivative Works thereof in any medium, with or without
+   modifications, and in Source or Object form, provided that You
+   meet the following conditions:
+
+   (a) You must give any other recipients of the Work or
+       Derivative Works a copy of this License; and
+
+   (b) You must cause any modified files to carry prominent notices
+       stating that You changed the files; and
+
+   (c) You must retain, in the Source form of any Derivative Works
+       that You distribute, all copyright, patent, trademark, and
+       attribution notices from the Source form of the Work,
+       excluding those notices that do not pertain to any part of
+       the Derivative Works; and
+
+   (d) If the Work includes a "NOTICE" text file as part of its
+       distribution, then any Derivative Works that You distribute must
+       include a readable copy of the attribution notices contained
+       within such NOTICE file, excluding those notices that do not
+       pertain to any part of the Derivative Works, in at least one
+       of the following places: within a NOTICE text file distributed
+       as part of the Derivative Works; within the Source form or
+       documentation, if provided along with the Derivative Works; or,
+       within a display generated by the Derivative Works, if and
+       wherever such third-party notices normally appear. The contents
+       of the NOTICE file are for informational purposes only and
+       do not modify the License. You may add Your own attribution
+       notices within Derivative Works that You distribute, alongside
+       or as an addendum to the NOTICE text from the Work, provided
+       that such additional attribution notices cannot be construed
+       as modifying the License.
+
+   You may add Your own copyright statement to Your modifications and
+   may provide additional or different license terms and conditions
+   for use, reproduction, or distribution of Your modifications, or
+   for any such Derivative Works as a whole, provided Your use,
+   reproduction, and distribution of the Work otherwise complies with
+   the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+   any Contribution intentionally submitted for inclusion in the Work
+   by You to the Licensor shall be under the terms and conditions of
+   this License, without any additional terms or conditions.
+   Notwithstanding the above, nothing herein shall supersede or modify
+   the terms of any separate license agreement you may have executed
+   with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+   names, trademarks, service marks, or product names of the Licensor,
+   except as required for reasonable and customary use in describing the
+   origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+   agreed to in writing, Licensor provides the Work (and each
+   Contributor provides its Contributions) on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+   implied, including, without limitation, any warranties or conditions
+   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+   PARTICULAR PURPOSE. You are solely responsible for determining the
+   appropriateness of using or redistributing the Work and assume any
+   risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+   whether in tort (including negligence), contract, or otherwise,
+   unless required by applicable law (such as deliberate and grossly
+   negligent acts) or agreed to in writing, shall any Contributor be
+   liable to You for damages, including any direct, indirect, special,
+   incidental, or consequential damages of any character arising as a
+   result of this License or out of the use or inability to use the
+   Work (including but not limited to damages for loss of goodwill,
+   work stoppage, computer failure or malfunction, or any and all
+   other commercial damages or losses), even if such Contributor
+   has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+   the Work or Derivative Works thereof, You may choose to offer,
+   and charge a fee for, acceptance of support, warranty, indemnity,
+   or other liability obligations and/or rights consistent with this
+   License. However, in accepting such obligations, You may act only
+   on Your own behalf and on Your sole responsibility, not on behalf
+   of any other Contributor, and only if You agree to indemnify,
+   defend, and hold each Contributor harmless for any liability
+   incurred by, or claims asserted against, such Contributor by reason
+   of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+   To apply the Apache License to your work, attach the following
+   boilerplate notice, with the fields enclosed by brackets "[]"
+   replaced with your own identifying information. (Don't include
+   the brackets!)  The text should be enclosed in the appropriate
+   comment syntax for the file format. We also recommend that a
+   file or class name and description of purpose be included on the
+   same "printed page" as the copyright notice for easier
+   identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+~~~~
+
+
+
+
+

BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)

+

Used by

+
    +
  • subtle 2.6.1
  • +
+
Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.
+Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
+
+
+
+

BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)

+

Used by

+
    +
  • snap 1.1.1
  • +
+
Copyright 2011, The Snappy-Rust Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+    * Neither the name of the copyright holder nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+
+

ISC License (ISC)

+

Used by

+
    +
  • untrusted 0.9.0
  • +
+
// Copyright 2015-2016 Brian Smith.
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+
+
+

ISC License (ISC)

+

Used by

+
    +
  • ring 0.17.14
  • +
+
Copyright 2015-2025 Brian Smith.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
+OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
+CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+
+
+

ISC License (ISC)

+

Used by

+
    +
  • rustls-webpki 0.103.13
  • +
+
Except as otherwise noted, this project is licensed under the following
+(ISC-style) terms:
+
+Copyright 2015 Brian Smith.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+The files under third-party/chromium are licensed as described in
+third-party/chromium/LICENSE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • mio 1.2.1
  • +
+
Copyright (c) 2014 Carl Lerche and other MIO contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • hyper 1.10.1
  • +
+
Copyright (c) 2014-2026 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • h2 0.4.15
  • +
+
Copyright (c) 2017 h2 authors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • bytes 1.12.1
  • +
+
Copyright (c) 2018 Carl Lerche
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • want 0.3.1
  • +
+
Copyright (c) 2018-2019 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • try-lock 0.2.5
  • +
+
Copyright (c) 2018-2023 Sean McArthur
+Copyright (c) 2016 Alex Crichton
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • slab 0.4.12
  • +
+
Copyright (c) 2019 Carl Lerche
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tracing-attributes 0.1.31
  • +
  • tracing-core 0.1.36
  • +
  • tracing 0.1.44
  • +
+
Copyright (c) 2019 Tokio Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tower-layer 0.3.3
  • +
  • tower-service 0.3.3
  • +
  • tower 0.5.3
  • +
+
Copyright (c) 2019 Tower Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tower-http 0.6.11
  • +
+
Copyright (c) 2019-2021 Tower Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • http-body-util 0.1.4
  • +
  • http-body 1.1.0
  • +
+
Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • hyper-util 0.1.20
  • +
+
Copyright (c) 2023-2025 Sean McArthur
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • synstructure 0.13.2
  • +
+
Copyright 2016 Nika Layzell
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tap 1.0.1
  • +
+
MIT License
+
+Copyright (c) 2017 Elliot Linder <darfink@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • bitvec 1.1.1
  • +
  • wyz 0.5.1
  • +
+
MIT License
+
+Copyright (c) 2018 myrrlyn (Alexander Payne)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tokio-macros 2.7.0
  • +
+
MIT License
+
+Copyright (c) 2019 Yoshua Wuyts
+Copyright (c) Tokio Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • radium 0.7.0
  • +
+
MIT License
+
+Copyright (c) 2019 kneecaw (Nika Layzell)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • funty 2.0.0
  • +
+
MIT License
+
+Copyright (c) 2020 myrrlyn (Alexander Payne)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • arrow-array 59.2.0
  • +
+
MIT License
+
+Copyright (c) 2020-2022 Oliver Margetts
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • convert_case 0.11.0
  • +
+
MIT License
+
+Copyright (c) 2025 rutrum
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • libm 0.2.16
  • +
  • pgrx-macros 0.19.2
  • +
  • pgrx-pg-sys 0.19.2
  • +
  • pgrx-sql-entity-graph 0.19.2
  • +
  • pgrx 0.19.2
  • +
  • seahash 4.1.0
  • +
+
MIT License
+
+Copyright (c) <year> <copyright holders>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • tokio-util 0.7.18
  • +
  • tokio 1.53.1
  • +
+
MIT License
+
+Copyright (c) Tokio Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • zmij 1.0.21
  • +
+
Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • spin 0.10.1
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2014 Mathijs van de Nes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • memchr 2.8.2
  • +
  • walkdir 2.5.0
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2015 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • nix 0.31.3
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2015 Carl Lerche + nix-rust Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • twox-hash 2.1.2
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2015 Jake Goulding
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • unescape 0.1.0
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2016 Saghm Rossi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • same-file 1.0.6
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2017 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • zstd 0.13.3
  • +
+
The MIT License (MIT)
+Copyright (c) 2016 Alexandre Bury
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • generic-array 0.14.7
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2015 Bartłomiej Kamiński
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+

MIT License (MIT)

+

Used by

+
    +
  • quick-xml 0.41.0
  • +
+
The MIT License (MIT)
+
+Copyright (c) 2016 Johann Tuffe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

Unicode License v3 (Unicode-3.0)

+

Used by

+
    +
  • unicode-ident 1.0.24
  • +
+
UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 1991-2023 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+
+
+
+

Unicode License v3 (Unicode-3.0)

+

Used by

+
    +
  • icu_collections 2.2.0
  • +
  • icu_locale_core 2.2.0
  • +
  • icu_normalizer 2.2.0
  • +
  • icu_normalizer_data 2.2.0
  • +
  • icu_properties 2.2.0
  • +
  • icu_properties_data 2.2.0
  • +
  • icu_provider 2.2.0
  • +
  • litemap 0.8.2
  • +
  • potential_utf 0.1.5
  • +
  • tinystr 0.8.3
  • +
  • writeable 0.6.3
  • +
  • yoke-derive 0.8.2
  • +
  • yoke 0.8.3
  • +
  • zerofrom-derive 0.1.7
  • +
  • zerofrom 0.1.8
  • +
  • zerotrie 0.2.4
  • +
  • zerovec-derive 0.11.3
  • +
  • zerovec 0.11.6
  • +
+
UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 2020-2024 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+
+SPDX-License-Identifier: Unicode-3.0
+
+—
+
+Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
+ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
+
+
+
+

zlib License (Zlib)

+

Used by

+
    +
  • foldhash 0.1.5
  • +
+
Copyright (c) 2024 Orson Peters
+
+This software is provided 'as-is', without any express or implied warranty. In
+no event will the authors be held liable for any damages arising from the use of
+this software.
+
+Permission is granted to anyone to use this software for any purpose, including
+commercial applications, and to alter it and redistribute it freely, subject to
+the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not claim
+    that you wrote the original software. If you use this software in a product,
+    an acknowledgment in the product documentation would be appreciated but is
+    not required.
+
+2. Altered source versions must be plainly marked as such, and must not be
+    misrepresented as being the original software.
+
+3. This notice may not be removed or altered from any source distribution.
+
+ + diff --git a/about.toml b/about.toml new file mode 100644 index 00000000..7423e6a2 --- /dev/null +++ b/about.toml @@ -0,0 +1,27 @@ +# License selection and validation for the third-party notice bundle. +# Keep this list intentionally explicit: a newly introduced license must be +# reviewed before it can be included in a KoldStore release. +accepted = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "Unicode-3.0", + "Unlicense", + "Zlib", +] + +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", +] + +ignore-build-dependencies = true +ignore-dev-dependencies = true +workarounds = ["ring", "rustls"] diff --git a/benchmarks/README.md b/benchmarks/README.md index 0a8b2be5..95c4eabe 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -95,6 +95,11 @@ KOLDSTORE_BENCH_START_PGRX=0 ./benchmarks/scripts/run.sh That server must have `wal_level=logical` and `shared_preload_libraries` including `koldstore` (restart required after changing either). +When `KOLDSTORE_BENCH_START_PGRX=0`, cold storage defaults to +`${TMPDIR:-/tmp}/koldstore-bench-cold` so a packaged `postgres` OS user can write +Parquet (workspace paths under `/home/runner/…` fail with permission denied). +Override with `KOLDSTORE_BENCH_STORAGE_ROOT` if needed. + ## Run Only Criterion ```bash diff --git a/benchmarks/benches/extension_serialization.rs b/benchmarks/benches/extension_serialization.rs index 829c35ee..02da540c 100644 --- a/benchmarks/benches/extension_serialization.rs +++ b/benchmarks/benches/extension_serialization.rs @@ -1,10 +1,10 @@ use std::hint::black_box; -use criterion::{criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use koldstore::merge_scan::plan::{MergeScanPlan, SegmentHint}; use koldstore_catalog::FlushPolicy; use koldstore_common::{ColdRow, HotRow, LogicalPk, PkColumn, PkValue, RowImage, ScopeKey, SeqId}; -use koldstore_merge::resolve_rows; +use koldstore_merge::{resolve_rows, NewestFirstWinnerResolver}; use koldstore_storage::PathTemplate; use serde_json::json; @@ -28,6 +28,25 @@ fn bench_hot_cold_deduplication(c: &mut Criterion) { }); } +fn bench_streaming_hot_cold_resolution(c: &mut Criterion) { + let hot = hot_rows(10_000); + let cold = cold_rows(10_000); + c.bench_function("streaming_resolve_hot_then_cold_by_primary_key", |b| { + b.iter_batched( + || (hot.clone(), cold.clone()), + |(hot, cold)| { + let mut resolver = NewestFirstWinnerResolver::default(); + let hot = resolver.resolve_hot_batch(hot).expect("hot batch resolves"); + let cold = resolver + .resolve_cold_batch(cold) + .expect("cold batch resolves"); + black_box((hot, cold, resolver.seen_key_count())) + }, + BatchSize::LargeInput, + ) + }); +} + fn bench_path_and_policy(c: &mut Criterion) { let template = PathTemplate::new("{namespace}/{tableName}/{scopeId}/"); c.bench_function("cold_object_path_generation", |b| { @@ -185,6 +204,7 @@ criterion_group!( benches, bench_merge_plan_serialization, bench_hot_cold_deduplication, + bench_streaming_hot_cold_resolution, bench_path_and_policy, bench_query_mode_decision ); diff --git a/benchmarks/scripts/run.sh b/benchmarks/scripts/run.sh index a541c16e..2cd4e326 100755 --- a/benchmarks/scripts/run.sh +++ b/benchmarks/scripts/run.sh @@ -7,6 +7,30 @@ SQL_DIR="$ROOT_DIR/benchmarks/sql" PGBENCH_DIR="$ROOT_DIR/benchmarks/pgbench" RESULTS_DIR="$ROOT_DIR/benchmarks/results" +# Cold Parquet must be writable by the PostgreSQL OS user. pgrx backends run as +# the invoking user (colocating under results/ is fine). Packaged Postgres runs +# as `postgres` and cannot create paths under the workspace (Permission denied / +# AppArmor), so external-server runs default to /tmp unless overridden. +resolve_bench_storage_root() { + if [[ -n "${KOLDSTORE_BENCH_STORAGE_ROOT:-}" ]]; then + printf '%s\n' "$KOLDSTORE_BENCH_STORAGE_ROOT" + elif [[ "${KOLDSTORE_BENCH_START_PGRX:-1}" == "0" ]]; then + local tmp="${TMPDIR:-/tmp}" + printf '%s\n' "${tmp%/}/koldstore-bench-cold" + else + printf '%s\n' "$RESULTS_DIR/cold-storage" + fi +} + +prepare_bench_storage_path() { + local path="${1:?}" + mkdir -p "$path" + # Best-effort DAC widen so a packaged `postgres` OS user can write Parquet + # when the path is still somewhere the backend is allowed to access. + chmod a+rwX "$(dirname "$path")" 2>/dev/null || true + chmod -R a+rwX "$path" 2>/dev/null || true +} + psql_in_mode() { # Extension modes rely on shared_preload_libraries=koldstore (set when the # pgrx server is started below). Baseline must not need the extension. @@ -40,7 +64,7 @@ DROP EXTENSION IF EXISTS koldstore CASCADE; DROP SCHEMA IF EXISTS koldstore CASCADE; SQL rm -rf "$KOLDSTORE_BENCH_STORAGE_PATH" - mkdir -p "$KOLDSTORE_BENCH_STORAGE_PATH" + prepare_bench_storage_path "$KOLDSTORE_BENCH_STORAGE_PATH" } setup_mode() { @@ -621,12 +645,15 @@ run_mode() { RAW_DIR="$RESULTS_DIR/raw/$MODE" PLAN_DIR="$RESULTS_DIR/plans/$MODE" - KOLDSTORE_BENCH_STORAGE_PATH="$RESULTS_DIR/cold-storage/$MODE" + BENCH_STORAGE_ROOT="$(resolve_bench_storage_root)" + KOLDSTORE_BENCH_STORAGE_PATH="$BENCH_STORAGE_ROOT/$MODE" export BENCH_SCHEMA + export KOLDSTORE_BENCH_STORAGE_ROOT="$BENCH_STORAGE_ROOT" export KOLDSTORE_BENCH_STORAGE_PATH - mkdir -p "$RAW_DIR" "$PLAN_DIR" "$KOLDSTORE_BENCH_STORAGE_PATH" + mkdir -p "$RAW_DIR" "$PLAN_DIR" + prepare_bench_storage_path "$KOLDSTORE_BENCH_STORAGE_PATH" ensure_postgres_ready "$MODE" || return 1 setup_mode @@ -822,9 +849,13 @@ export BENCH_MIXED_JOBS="${BENCH_MIXED_JOBS:-2}" export KOLDSTORE_BENCH_COMPRESSION="${KOLDSTORE_BENCH_COMPRESSION:-zstd}" export KOLDSTORE_BENCH_SKIP_CRITERION="${KOLDSTORE_BENCH_SKIP_CRITERION:-1}" +BENCH_STORAGE_ROOT="$(resolve_bench_storage_root)" +export KOLDSTORE_BENCH_STORAGE_ROOT="$BENCH_STORAGE_ROOT" + echo "running pgKalam benchmark suite" echo "BENCH_PROFILE=$BENCH_PROFILE" echo "DATABASE_URL=$DATABASE_URL" +echo "BENCH_STORAGE_ROOT=$BENCH_STORAGE_ROOT" echo "BENCH_ROWS=$BENCH_ROWS BENCH_HOT_LIMIT=$BENCH_HOT_LIMIT BENCH_SECONDS=$BENCH_SECONDS BENCH_MIXED_SECONDS=$BENCH_MIXED_SECONDS" echo "BENCH_CLIENTS=$BENCH_CLIENTS BENCH_JOBS=$BENCH_JOBS BENCH_MIXED_CLIENTS=$BENCH_MIXED_CLIENTS BENCH_MIXED_JOBS=$BENCH_MIXED_JOBS" @@ -835,6 +866,7 @@ if [[ "${KOLDSTORE_BENCH_CLEAN_RESULTS:-1}" != "0" ]]; then "$RESULTS_DIR/raw" \ "$RESULTS_DIR/plans" \ "$RESULTS_DIR/cold-storage" \ + "$BENCH_STORAGE_ROOT" \ "$RESULTS_DIR/summary.json" \ "$RESULTS_DIR/report.md" \ "$RESULTS_DIR/report.html" diff --git a/benchmarks/src/main.rs b/benchmarks/src/main.rs index aa583d07..7775d657 100644 --- a/benchmarks/src/main.rs +++ b/benchmarks/src/main.rs @@ -203,10 +203,12 @@ async fn setup_database(config: &BenchmarkConfig) -> Result { Ok(version) } -/// Pins the async apply worker off and terminates any persistent WAL applier. +/// Pins the async apply worker off and force-stops any persistent WAL applier. /// /// Matches the storage-comparison measurement control: manage may start the /// service for activation, then foreground OLTP benches run without apply load. +/// GUC-off alone is insufficient under the sticky supervisor — required WAL +/// services respawn unless dispatch is paused. async fn disable_async_worker_for_foreground_bench(client: &tokio_postgres::Client) -> Result<()> { let dbname: String = client .query_one("SELECT current_database()", &[]) @@ -225,6 +227,14 @@ async fn disable_async_worker_for_foreground_bench(client: &tokio_postgres::Clie .await .context("disable async mirror worker GUC in benchmark session")?; + client + .query_one( + "SELECT koldstore.internal_set_async_mirror_ensure_paused(true)", + &[], + ) + .await + .context("pause supervisor WAL/maintenance dispatch for foreground benches")?; + for _ in 0..40 { let _ = client .query_one( @@ -256,7 +266,9 @@ async fn disable_async_worker_for_foreground_bench(client: &tokio_postgres::Clie } tokio::time::sleep(std::time::Duration::from_millis(25)).await; } - Ok(()) + anyhow::bail!( + "persistent WAL applier still running after pause+terminate; foreground latency would be contaminated" + ) } async fn run_pair( diff --git a/crates/koldstore-catalog/src/decode.rs b/crates/koldstore-catalog/src/decode.rs index 72a46615..00bfd035 100644 --- a/crates/koldstore-catalog/src/decode.rs +++ b/crates/koldstore-catalog/src/decode.rs @@ -479,6 +479,32 @@ mod tests { assert_eq!(meta.order_column.as_ref().unwrap().type_oid, 1184); } + #[test] + fn async_managed_relation_rejects_incomplete_order_column() { + let value = serde_json::json!({ + "table_oid": "42", + "mirror": "koldstore.items__cl", + "primary_key": ["id"], + "segment_order_column": "id", + "segment_order_type_oid": null + }); + let err = super::async_managed_relation(&value).unwrap_err(); + assert!(err.contains("incomplete segment_order_column")); + } + + #[test] + fn async_managed_relation_allows_absent_order_column() { + let value = serde_json::json!({ + "table_oid": "42", + "mirror": "koldstore.items__cl", + "primary_key": ["id"], + "segment_order_column": null, + "segment_order_type_oid": null + }); + let meta = super::async_managed_relation(&value).unwrap(); + assert!(meta.order_column.is_none()); + } + #[test] fn flush_storage_context_requires_compression_codec() { let value = serde_json::json!({ diff --git a/crates/koldstore-catalog/src/queries.rs b/crates/koldstore-catalog/src/queries.rs index c8b8a72f..b282630b 100644 --- a/crates/koldstore-catalog/src/queries.rs +++ b/crates/koldstore-catalog/src/queries.rs @@ -117,9 +117,16 @@ SELECT (SELECT jsonb_build_object( FROM jsonb_array_elements(s.primary_key) WITH ORDINALITY AS t(elem, ord) ), 'segment_order_column_id', (s.options->>'segment_order_column_id')::int, + -- Name and type_oid must resolve together via pg_attribute. A name-only + -- hit (orphaned active schema after DROP SCHEMA CASCADE, dropped column) + -- would otherwise fail async decode with incomplete segment_order fields. 'segment_order_column', ( SELECT c->>'name' FROM jsonb_array_elements(s.columns) AS c + JOIN pg_catalog.pg_attribute a + ON a.attrelid = s.table_oid + AND a.attnum = (c->>'column_id')::smallint + AND NOT a.attisdropped WHERE (c->>'column_id')::int = (s.options->>'segment_order_column_id')::int LIMIT 1 ), @@ -135,6 +142,7 @@ SELECT (SELECT jsonb_build_object( ) )::text FROM koldstore.schemas s +JOIN pg_catalog.pg_class rel ON rel.oid = s.table_oid WHERE s.active AND s.table_oid = $1::oid LIMIT 1) "#, @@ -1348,6 +1356,18 @@ mod tests { statement.sql.contains("a.atttypid"), "order-column type must come from pg_attribute, not missing columns.type_oid" ); + assert!( + statement.sql.contains("JOIN pg_catalog.pg_class rel"), + "orphaned active schemas must not resolve without a live relation" + ); + assert!( + statement + .sql + .matches("JOIN pg_catalog.pg_attribute a") + .count() + >= 2, + "segment_order name and type_oid must both require pg_attribute" + ); assert!(!statement.sql.contains("c->>'type_oid'")); } diff --git a/crates/koldstore-common/src/config/mod.rs b/crates/koldstore-common/src/config/mod.rs index 291c129c..14e92665 100644 --- a/crates/koldstore-common/src/config/mod.rs +++ b/crates/koldstore-common/src/config/mod.rs @@ -8,6 +8,6 @@ pub mod privileges; pub use options::{ flush_enabled_from_options, hot_row_limit_from_options, validate_max_rows_per_file, - FlushPolicy, ManageTableOptions, MigrationStatus, MoveAfter, ParquetCompression, - DEFAULT_MAX_ROWS_PER_FLUSH, DEFAULT_MIN_MAX_ROWS_PER_FILE, + FlushPolicy, ManageTableOptions, MigrationStatus, MoveAfter, ParquetBloomFilterFpp, + ParquetCompression, DEFAULT_MAX_ROWS_PER_FLUSH, DEFAULT_MIN_MAX_ROWS_PER_FILE, }; diff --git a/crates/koldstore-common/src/config/options.rs b/crates/koldstore-common/src/config/options.rs index a0a08b8e..ad54468d 100644 --- a/crates/koldstore-common/src/config/options.rs +++ b/crates/koldstore-common/src/config/options.rs @@ -64,6 +64,38 @@ pub enum ParquetCompression { Uncompressed, } +/// Validated false-positive probability for Parquet Bloom filters. +/// +/// The finite `0 < value < 1` invariant makes the wrapped floating-point +/// value safe to compare and persist as managed-table configuration. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ParquetBloomFilterFpp(f64); + +impl Eq for ParquetBloomFilterFpp {} + +impl ParquetBloomFilterFpp { + /// Creates a validated Parquet Bloom false-positive probability. + /// + /// # Errors + /// + /// Returns an error unless `value` is finite and strictly between zero and + /// one. + pub fn new(value: f64) -> Result { + if value.is_finite() && value > 0.0 && value < 1.0 { + Ok(Self(value)) + } else { + Err("parquet_bloom_filter_fpp must be greater than 0 and less than 1".into()) + } + } + + /// Returns the probability expected by parquet-rs. + #[must_use] + pub const fn get(self) -> f64 { + self.0 + } +} + impl ParquetCompression { /// Returns the persisted JSON string for this codec. #[must_use] @@ -290,6 +322,15 @@ pub struct ManageTableOptions { /// Primary-key columns are always forced into the effective Bloom set. #[serde(default, skip_serializing_if = "Option::is_none")] pub bloom_filter_columns: Option>, + /// Optional Parquet row-group row limit for future flushes. + #[serde(skip_serializing_if = "Option::is_none")] + pub parquet_row_group_size: Option, + /// Optional Parquet data-page row limit for future flushes. + #[serde(skip_serializing_if = "Option::is_none")] + pub parquet_data_page_row_count_limit: Option, + /// Optional false-positive probability for Parquet Bloom filters. + #[serde(skip_serializing_if = "Option::is_none")] + pub parquet_bloom_filter_fpp: Option, } impl ManageTableOptions { @@ -327,6 +368,14 @@ impl ManageTableOptions { "bloom_filter_columns", decoded.bloom_filter_columns.as_deref(), )?; + validate_positive_option("parquet_row_group_size", decoded.parquet_row_group_size)?; + validate_positive_option( + "parquet_data_page_row_count_limit", + decoded.parquet_data_page_row_count_limit, + )?; + if let Some(fpp) = decoded.parquet_bloom_filter_fpp { + ParquetBloomFilterFpp::new(fpp.get())?; + } Ok(decoded) } @@ -501,6 +550,34 @@ impl ManageTableOptions { }; self } + + /// Sets the Parquet row-group row limit for future flushes. + #[must_use] + pub const fn with_parquet_row_group_size(mut self, row_count: u64) -> Self { + self.parquet_row_group_size = Some(row_count); + self + } + + /// Sets the Parquet data-page row limit for future flushes. + #[must_use] + pub const fn with_parquet_data_page_row_count_limit(mut self, row_count: u64) -> Self { + self.parquet_data_page_row_count_limit = Some(row_count); + self + } + + /// Sets the Parquet Bloom filter false-positive probability for future flushes. + #[must_use] + pub const fn with_parquet_bloom_filter_fpp(mut self, fpp: ParquetBloomFilterFpp) -> Self { + self.parquet_bloom_filter_fpp = Some(fpp); + self + } +} + +fn validate_positive_option(field: &str, value: Option) -> Result<(), String> { + if value == Some(0) { + return Err(format!("{field} must be greater than zero")); + } + Ok(()) } fn normalize_column_name_list(columns: I) -> Vec diff --git a/crates/koldstore-common/src/lib.rs b/crates/koldstore-common/src/lib.rs index 61c6823a..0b49bee0 100644 --- a/crates/koldstore-common/src/lib.rs +++ b/crates/koldstore-common/src/lib.rs @@ -29,8 +29,8 @@ pub use cell::{CellValue, RowImage}; pub use column::{ColumnId, ColumnRef}; pub use config::{ flush_enabled_from_options, hot_row_limit_from_options, validate_max_rows_per_file, - FlushPolicy, ManageTableOptions, MigrationStatus, MoveAfter, ParquetCompression, - DEFAULT_MAX_ROWS_PER_FLUSH, DEFAULT_MIN_MAX_ROWS_PER_FILE, + FlushPolicy, ManageTableOptions, MigrationStatus, MoveAfter, ParquetBloomFilterFpp, + ParquetCompression, DEFAULT_MAX_ROWS_PER_FLUSH, DEFAULT_MIN_MAX_ROWS_PER_FILE, }; pub use error::{Diagnostic, KoldstoreError, Result}; pub use filter::{ColumnClass, Predicate, PredicateClass, PredicateValue}; diff --git a/crates/koldstore-common/src/sql/statement.rs b/crates/koldstore-common/src/sql/statement.rs index fcdd13df..57798630 100644 --- a/crates/koldstore-common/src/sql/statement.rs +++ b/crates/koldstore-common/src/sql/statement.rs @@ -40,6 +40,8 @@ pub enum SqlAccess { pub enum SqlParamType { BigInt, Integer, + /// PostgreSQL `timestamp with time zone`. + TimestampWithTimeZone, Text, Jsonb, Bytea, diff --git a/crates/koldstore-flush/src/encode.rs b/crates/koldstore-flush/src/encode.rs index df4d2420..900eaadc 100644 --- a/crates/koldstore-flush/src/encode.rs +++ b/crates/koldstore-flush/src/encode.rs @@ -22,6 +22,8 @@ pub fn writer_options_from_encode_input(input: &StreamEncodeInput) -> WriterOpti WriterOptions { compression: input.compression.clone(), row_group_size: input.row_group_size.max(1), + data_page_row_count_limit: input.data_page_row_count_limit, + bloom_filter_false_positive_rate: input.bloom_filter_false_positive_rate, ..WriterOptions::default() } .with_statistics_columns( @@ -77,6 +79,10 @@ pub struct StreamEncodeInput { pub compression: String, /// Rows encoded per streaming row group. pub row_group_size: usize, + /// Optional row cap per Parquet data page. + pub data_page_row_count_limit: Option, + /// Optional false-positive probability for Parquet Bloom filters. + pub bloom_filter_false_positive_rate: Option, /// When set, mirror fetch is restricted to these operation codes. pub mirror_ops: Option>, /// Ask PostgreSQL to return rows ordered by mirror `order_key`, then PK, then seq. @@ -382,6 +388,8 @@ mod tests { target_file_size_bytes, compression: "zstd".to_string(), row_group_size: 1, + data_page_row_count_limit: None, + bloom_filter_false_positive_rate: None, mirror_ops: None, sort_by_order_key: false, order_key_column: None, diff --git a/crates/koldstore-flush/src/lib.rs b/crates/koldstore-flush/src/lib.rs index f3d84445..ac4c23ee 100644 --- a/crates/koldstore-flush/src/lib.rs +++ b/crates/koldstore-flush/src/lib.rs @@ -43,8 +43,9 @@ pub use ops::{ classify_command, flush_table_request, plan_count_pending_flush_jobs, plan_enqueue_or_lookup_flush_job, plan_koldstore_exec, plan_mirror_flush_selection_batch, plan_mirror_flush_selection_batch_with_order_key, plan_next_pending_flush_due_epoch_ms, - plan_select_pending_flush_candidates, sql_param_cast, table_status_plan, FlushJobEnqueuePlan, - FlushRequest, KoldstoreExecPlan, MirrorFlushSelectionPlan, OpsCommand, OpsError, + plan_select_pending_flush_candidates, plan_select_pending_flush_candidates_after, + sql_param_cast, table_status_plan, FlushJobEnqueuePlan, FlushRequest, KoldstoreExecPlan, + MirrorFlushSelectionPlan, OpsCommand, OpsError, }; pub use policy::{policy_flush_row_count, selected_rows_meet_file_minimum}; pub use retention::{plan_purge_old_jobs, JobRetentionError, DEFAULT_PURGE_BATCH_LIMIT}; diff --git a/crates/koldstore-flush/src/ops.rs b/crates/koldstore-flush/src/ops.rs index ccc184cb..8bf39d20 100644 --- a/crates/koldstore-flush/src/ops.rs +++ b/crates/koldstore-flush/src/ops.rs @@ -243,7 +243,7 @@ RETURNING id }) } -/// Plans a fair page of due pending flush jobs for one-shot executors. +/// Plans the first fair page of due pending flush jobs for one-shot executors. /// /// Bind `$1` = page size (`LIMIT`). Does not lock jobs rows; session table /// ownership serializes the subsequent claim. @@ -256,6 +256,7 @@ pub fn plan_select_pending_flush_candidates() -> Result "select pending flush candidates", r#" SELECT table_oid::oid, COALESCE((payload->>'force')::boolean, false) + , available_at, updated_at, id FROM koldstore.jobs WHERE job_type = 'flush' AND status = 'pending' @@ -268,6 +269,40 @@ LIMIT $1 .map_err(|error| OpsError::Sql(error.to_string())) } +/// Plans the next fair page of due pending flush jobs after a stable queue key. +/// +/// Bind `$1` = page size and `$2..$4` = the last seen +/// `(available_at, updated_at, id)` key. Keyset paging avoids both a permanently +/// blocked first page and the skipped rows that offset paging can cause while +/// other executors claim jobs concurrently. +/// +/// # Errors +/// +/// Returns an error when SPI statement metadata cannot be prepared. +pub fn plan_select_pending_flush_candidates_after() -> Result { + SqlStatement::read_with_params( + "select pending flush candidates after cursor", + r#" +SELECT table_oid::oid, COALESCE((payload->>'force')::boolean, false) + , available_at, updated_at, id +FROM koldstore.jobs +WHERE job_type = 'flush' + AND status = 'pending' + AND available_at <= clock_timestamp() + AND (available_at, updated_at, id) > ($2, $3, $4) +ORDER BY available_at, updated_at, id +LIMIT $1 +"#, + [ + SqlParamType::BigInt, + SqlParamType::TimestampWithTimeZone, + SqlParamType::TimestampWithTimeZone, + SqlParamType::Uuid, + ], + ) + .map_err(|error| OpsError::Sql(error.to_string())) +} + /// Plans the earliest pending flush `available_at` as Unix epoch milliseconds. /// /// Used to schedule the next supervisor wake when the fair page is empty. @@ -389,6 +424,7 @@ pub fn sql_param_cast(param_index: usize, param_type: SqlParamType) -> String { let cast = match param_type { SqlParamType::BigInt => "bigint", SqlParamType::Integer => "integer", + SqlParamType::TimestampWithTimeZone => "timestamp with time zone", SqlParamType::Text => "text", SqlParamType::Jsonb => "jsonb", SqlParamType::Bytea => "bytea", @@ -460,9 +496,7 @@ fn plan_mirror_flush_selection_inner( let mut where_clauses = vec!["mirror.\"seq\" <= $1::bigint".to_string()]; let mut param_types = vec![SqlParamType::BigInt]; - let limit_param; - let order_by; - if sort_by_order_key { + let (limit_param, order_by) = if sort_by_order_key { // $2 = first page; $3 = after order_key; $4.. = after PK; then after seq + limit. let after_order_key_param = 3_usize; let mut keyset_left = vec!["mirror.\"order_key\"".to_string()]; @@ -488,21 +522,19 @@ fn plan_mirror_flush_selection_inner( left = keyset_left.join(", "), right = keyset_right.join(", "), )); - limit_param = after_seq_param + 1; param_types.push(SqlParamType::BigInt); let mut order_parts = vec!["mirror.\"order_key\" ASC NULLS LAST".to_string()]; for pk_column in &pk_columns { order_parts.push(format!("mirror.{pk_column} ASC")); } order_parts.push("mirror.\"seq\" ASC".to_string()); - order_by = order_parts.join(", "); + (after_seq_param + 1, order_parts.join(", ")) } else { where_clauses.push("mirror.\"seq\" > $2::bigint".to_string()); param_types.push(SqlParamType::BigInt); - limit_param = 3_usize; param_types.push(SqlParamType::BigInt); - order_by = "mirror.\"seq\" ASC".to_string(); - } + (3_usize, "mirror.\"seq\" ASC".to_string()) + }; if let Some(ops) = mirror_ops { if !ops.is_empty() { where_clauses diff --git a/crates/koldstore-flush/tests/ops_functions.rs b/crates/koldstore-flush/tests/ops_functions.rs index bd222ee4..c2cf9da3 100644 --- a/crates/koldstore-flush/tests/ops_functions.rs +++ b/crates/koldstore-flush/tests/ops_functions.rs @@ -168,8 +168,23 @@ fn flush_sql_requests_capture_table_scope_and_enqueue_metadata() { let pending = koldstore_flush::ops::plan_select_pending_flush_candidates().unwrap(); assert!(pending.sql.contains("status = 'pending'")); assert!(pending.sql.contains("'force'")); + assert!(pending.sql.contains("available_at, updated_at, id")); assert!(pending.sql.contains("LIMIT $1")); + let pending_after = koldstore_flush::ops::plan_select_pending_flush_candidates_after().unwrap(); + assert!(pending_after + .sql + .contains("(available_at, updated_at, id) > ($2, $3, $4)")); + assert_eq!( + pending_after.param_types, + vec![ + koldstore_common::SqlParamType::BigInt, + koldstore_common::SqlParamType::TimestampWithTimeZone, + koldstore_common::SqlParamType::TimestampWithTimeZone, + koldstore_common::SqlParamType::Uuid, + ] + ); + let next_due = koldstore_flush::ops::plan_next_pending_flush_due_epoch_ms().unwrap(); assert!(next_due.sql.contains("min(available_at)")); diff --git a/crates/koldstore-merge/src/core/overlay.rs b/crates/koldstore-merge/src/core/overlay.rs new file mode 100644 index 00000000..291550d0 --- /dev/null +++ b/crates/koldstore-merge/src/core/overlay.rs @@ -0,0 +1,61 @@ +//! Mirror tombstone overlay for masking older cold winners. + +use std::collections::HashSet; + +use koldstore_common::{ColdRow, LogicalPk}; + +/// Unflushed mirror tombstones that mask older cold Parquet rows. +#[derive(Debug, Default, Clone)] +pub struct MirrorOverlay { + masked_pks: HashSet, +} + +impl MirrorOverlay { + /// Creates an overlay from exact logical primary keys. + #[must_use] + pub fn new(masked_pks: impl IntoIterator) -> Self { + Self { + masked_pks: masked_pks.into_iter().collect(), + } + } + + /// Removes masked cold rows in place and returns the number removed. + pub fn retain_unmasked(&self, cold_rows: &mut Vec) -> usize { + let before = cold_rows.len(); + cold_rows.retain(|row| !self.masked_pks.contains(&row.pk)); + before.saturating_sub(cold_rows.len()) + } + + /// Adds one exact tombstone key, returning whether it was new. + pub fn insert(&mut self, pk: LogicalPk) -> bool { + self.masked_pks.insert(pk) + } + + /// Returns whether `pk` is masked by an unflushed tombstone. + #[must_use] + pub fn contains(&self, pk: &LogicalPk) -> bool { + self.masked_pks.contains(pk) + } + + /// Iterates exact masked keys without cloning them. + pub fn iter(&self) -> impl Iterator { + self.masked_pks.iter() + } + + /// Returns the number of distinct tombstone keys. + #[must_use] + pub fn len(&self) -> usize { + self.masked_pks.len() + } + + /// Returns whether the overlay has no tombstone keys. + #[must_use] + pub fn is_empty(&self) -> bool { + self.masked_pks.is_empty() + } + + /// Consumes the overlay into exact masked keys. + pub fn into_masked_pks(self) -> impl Iterator { + self.masked_pks.into_iter() + } +} diff --git a/crates/koldstore-merge/src/core/resolver.rs b/crates/koldstore-merge/src/core/resolver.rs index 5acab0f4..c211fe94 100644 --- a/crates/koldstore-merge/src/core/resolver.rs +++ b/crates/koldstore-merge/src/core/resolver.rs @@ -23,7 +23,8 @@ pub struct ResolvedRow { } struct Candidate { - pk_json: Option, + /// Retained only by streaming batches, then encoded for emitted winners. + pk: Option, source: RowSource, seq: SeqId, deleted: bool, @@ -32,16 +33,13 @@ struct Candidate { impl Candidate { fn beats(&self, other: &Self) -> bool { - self.seq > other.seq - || (self.seq == other.seq - && self.source == RowSource::Hot - && other.source == RowSource::Cold) + candidate_beats(self.seq, self.source, other.seq, other.source) } fn into_resolved(mut self, pk_json: Option) -> ResolvedRow { ResolvedRow { pk_json: pk_json - .or_else(|| self.pk_json.take()) + .or_else(|| self.pk.take().map(|pk| pk.to_canonical_json())) .expect("resolved candidates require canonical PK JSON"), source: self.source, seq: self.seq, @@ -51,10 +49,76 @@ impl Candidate { } } -/// Resolves hot and cold rows (borrowed inputs; clones row images). +struct BorrowedCandidate<'a> { + source: RowSource, + seq: SeqId, + deleted: bool, + row_image: &'a RowImage, +} + +impl BorrowedCandidate<'_> { + fn beats(&self, other: &Self) -> bool { + candidate_beats(self.seq, self.source, other.seq, other.source) + } + + fn to_resolved(&self, pk: &LogicalPk) -> ResolvedRow { + ResolvedRow { + pk_json: pk.to_canonical_json(), + source: self.source, + seq: self.seq, + row_image: self.row_image.clone(), + deleted: self.deleted, + } + } +} + +fn candidate_beats( + candidate_seq: SeqId, + candidate_source: RowSource, + current_seq: SeqId, + current_source: RowSource, +) -> bool { + candidate_seq > current_seq + || (candidate_seq == current_seq + && candidate_source == RowSource::Hot + && current_source == RowSource::Cold) +} + +/// Resolves borrowed hot and cold rows, cloning payloads only for final winners. #[must_use] pub fn resolve_rows(hot: &[HotRow], cold: &[ColdRow]) -> Vec { - resolve_rows_owned(hot.to_vec(), cold.to_vec()) + let mut winners: HashMap<&LogicalPk, BorrowedCandidate<'_>> = + HashMap::with_capacity(hot.len().max(cold.len())); + for row in cold { + insert_borrowed_candidate( + &mut winners, + &row.pk, + BorrowedCandidate { + source: RowSource::Cold, + seq: row.seq, + deleted: row.deleted, + row_image: &row.row_image, + }, + ); + } + for row in hot { + insert_borrowed_candidate( + &mut winners, + &row.pk, + BorrowedCandidate { + source: RowSource::Hot, + seq: row.seq, + deleted: row.deleted, + row_image: &row.row_image, + }, + ); + } + + winners + .into_iter() + .filter(|(_, winner)| !winner.deleted) + .map(|(pk, winner)| winner.to_resolved(pk)) + .collect() } /// Resolves hot and cold rows, taking ownership to avoid per-candidate image clones. @@ -63,44 +127,33 @@ pub fn resolve_rows(hot: &[HotRow], cold: &[ColdRow]) -> Vec { /// when winners leave the merge for the SQL/API boundary. #[must_use] pub fn resolve_rows_owned(hot: Vec, cold: Vec) -> Vec { - let mut winners: HashMap = HashMap::new(); + let mut winners: HashMap = + HashMap::with_capacity(hot.len().max(cold.len())); for row in cold { - let candidate = Candidate { - pk_json: None, - source: RowSource::Cold, - seq: row.seq, - deleted: row.deleted, - row_image: row.row_image, - }; - match winners.entry(row.pk) { - Entry::Vacant(slot) => { - slot.insert(candidate); - } - Entry::Occupied(mut slot) => { - if candidate.beats(slot.get()) { - slot.insert(candidate); - } - } - } + insert_owned_candidate( + &mut winners, + row.pk, + Candidate { + pk: None, + source: RowSource::Cold, + seq: row.seq, + deleted: row.deleted, + row_image: row.row_image, + }, + ); } for row in hot { - let candidate = Candidate { - pk_json: None, - source: RowSource::Hot, - seq: row.seq, - deleted: row.deleted, - row_image: row.row_image, - }; - match winners.entry(row.pk) { - Entry::Vacant(slot) => { - slot.insert(candidate); - } - Entry::Occupied(mut slot) => { - if candidate.beats(slot.get()) { - slot.insert(candidate); - } - } - } + insert_owned_candidate( + &mut winners, + row.pk, + Candidate { + pk: None, + source: RowSource::Hot, + seq: row.seq, + deleted: row.deleted, + row_image: row.row_image, + }, + ); } winners @@ -174,27 +227,9 @@ impl NewestFirstWinnerResolver { &mut self, rows: Vec, ) -> Result, SeenKeyLimitExceeded> { - let mut winners = HashMap::with_capacity(rows.len()); - let mut order = Vec::with_capacity(rows.len()); - for row in rows { - let identity = row.pk.clone().into_values(); - let is_new = !winners.contains_key(&identity); - insert_candidate( - &mut winners, - row.pk, - Candidate { - pk_json: None, - source: RowSource::Hot, - seq: row.seq, - deleted: row.deleted, - row_image: row.row_image, - }, - ); - if is_new { - order.push(identity); - } - } - self.take_unseen_ordered(winners, order) + self.resolve_batch(rows, RowSource::Hot, |row| { + (row.pk, row.seq, row.deleted, row.row_image) + }) } /// Resolves one cold batch older than every previously supplied batch. @@ -203,21 +238,32 @@ impl NewestFirstWinnerResolver { pub fn resolve_cold_batch( &mut self, rows: Vec, + ) -> Result, SeenKeyLimitExceeded> { + self.resolve_batch(rows, RowSource::Cold, |row| { + (row.pk, row.seq, row.deleted, row.row_image) + }) + } + + fn resolve_batch( + &mut self, + rows: Vec, + source: RowSource, + into_parts: impl Fn(R) -> (LogicalPk, SeqId, bool, RowImage), ) -> Result, SeenKeyLimitExceeded> { let mut winners = HashMap::with_capacity(rows.len()); let mut order = Vec::with_capacity(rows.len()); for row in rows { - let identity = row.pk.clone().into_values(); - let is_new = !winners.contains_key(&identity); - insert_candidate( + let (pk, seq, deleted, row_image) = into_parts(row); + let identity = pk.clone().into_values(); + let is_new = insert_candidate( &mut winners, - row.pk, + identity.clone(), Candidate { - pk_json: None, - source: RowSource::Cold, - seq: row.seq, - deleted: row.deleted, - row_image: row.row_image, + pk: Some(pk), + source, + seq, + deleted, + row_image, }, ); if is_new { @@ -300,20 +346,55 @@ impl NewestFirstWinnerResolver { } } -fn insert_candidate( - winners: &mut HashMap, +fn insert_borrowed_candidate<'a>( + winners: &mut HashMap<&'a LogicalPk, BorrowedCandidate<'a>>, + pk: &'a LogicalPk, + candidate: BorrowedCandidate<'a>, +) { + match winners.entry(pk) { + Entry::Vacant(slot) => { + slot.insert(candidate); + } + Entry::Occupied(mut slot) => { + if candidate.beats(slot.get()) { + slot.insert(candidate); + } + } + } +} + +fn insert_owned_candidate( + winners: &mut HashMap, pk: LogicalPk, - mut candidate: Candidate, + candidate: Candidate, ) { - candidate.pk_json = Some(pk.to_canonical_json()); - match winners.entry(pk.into_values()) { + match winners.entry(pk) { + Entry::Vacant(slot) => { + slot.insert(candidate); + } + Entry::Occupied(mut slot) => { + if candidate.beats(slot.get()) { + slot.insert(candidate); + } + } + } +} + +fn insert_candidate( + winners: &mut HashMap, + identity: LogicalPkValues, + candidate: Candidate, +) -> bool { + match winners.entry(identity) { Entry::Vacant(slot) => { slot.insert(candidate); + true } Entry::Occupied(mut slot) => { if candidate.beats(slot.get()) { slot.insert(candidate); } + false } } } diff --git a/crates/koldstore-merge/src/lib.rs b/crates/koldstore-merge/src/lib.rs index 168a4e86..ad8cf980 100644 --- a/crates/koldstore-merge/src/lib.rs +++ b/crates/koldstore-merge/src/lib.rs @@ -8,6 +8,8 @@ pub mod dml; pub mod events; #[path = "sql/managed_hook.rs"] pub mod managed_hook; +#[path = "core/overlay.rs"] +pub mod overlay; #[path = "planning/quals.rs"] pub mod quals; #[path = "core/resolver.rs"] @@ -35,6 +37,7 @@ pub use managed_hook::{ plan_managed_update_effect, simple_pk_delete_supported, ManagedDmlEffect, SimplePkPredicate, HOT_DML_MANIFEST_SYNC_STATE, }; +pub use overlay::MirrorOverlay; pub use quals::{build_pruning_plan, classify_predicates, ClassifiedPredicates, PruningPlan}; pub use resolver::{ resolve_rows, resolve_rows_owned, NewestFirstWinnerResolver, ResolvedRow, RowSource, @@ -44,14 +47,13 @@ pub use rls::{ enforce_or_fail_closed, plan_security_quals, unsupported_rls_error, SecurityQualPlan, }; pub use scan::{ - begin_merge_scan, begin_merge_scan_with_plan, build_path_portfolio, classify_path_strategy, - clear_partial_heap_paths, custom_scan_explain_label, execute_merge_scan, - execute_merge_scan_with_filters, group_segments_newest_first, group_segments_oldest_first, + build_path_portfolio, classify_path_strategy, clear_partial_heap_paths, + custom_scan_explain_label, group_segments_newest_first, group_segments_oldest_first, physical_name_for_segment_column, retain_pre_merge_cold_prune_predicates, - validate_prune_predicates_indexed, ColdAvailability, ColdPruneColumnPolicy, FilterPlan, - HotChildCandidate, KoldPathStrategy, MergeMetadataAttnums, MergeScanError, MergeScanPlan, - MergeScanResult, OrderColumnSupport, OrderedPathSpec, PathPortfolioDecision, PlannerPath, - PlannerPathKind, PortfolioPathEntry, ScanResourceCounters, ScanState, SegmentHint, - SegmentPrunePredicate, SegmentStatsHint, StrategyRequest, CUSTOM_PATH_NAME, HOT_SEQ_SENTINEL, + validate_prune_predicates_indexed, ColdProjectionPlan, ColdPruneColumnPolicy, + HotChildCandidate, KoldPathStrategy, MergeMetadataAttnums, MergeScanPlan, OrderColumnSupport, + OrderedPathSpec, PathPortfolioDecision, PlannerPath, PlannerPathKind, PortfolioPathEntry, + SegmentHint, SegmentPrunePredicate, SegmentStatsHint, StrategyRequest, CUSTOM_PATH_NAME, + HOT_SEQ_SENTINEL, }; pub use tombstone::{tombstone_required, TombstoneDecision}; diff --git a/crates/koldstore-merge/src/scan/exec.rs b/crates/koldstore-merge/src/scan/exec.rs deleted file mode 100644 index 052e0d99..00000000 --- a/crates/koldstore-merge/src/scan/exec.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! CustomScan execution models and pure hot/cold winner resolution. - -use koldstore_common::{ColdRow, HotRow}; -use thiserror::Error; - -use super::plan::MergeScanPlan; -use crate::resolver::{resolve_rows_owned, ResolvedRow}; - -/// Availability of cold storage for a scan. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ColdAvailability { - /// Cold storage can be opened. - Available, - /// Cold storage cannot be reached. - Unavailable, -} - -/// Merge scan execution errors. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum MergeScanError { - /// Cold segments are visible, so returning only hot rows would be incomplete. - #[error("cold data required for managed read, but cold storage is unavailable")] - ColdRequiredUnavailable, -} - -/// Merge scan execution state. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ScanState { - /// Managed table oid. - pub table_oid: u32, - /// Visible active segment paths. - pub visible_segments: Vec, - /// Selected row groups from safe pruning. - pub selected_row_groups: Vec, - /// Whether a PostgreSQL snapshot has been captured. - pub snapshot_captured: bool, - /// Whether cold streams have been opened. - pub cold_streams_open: bool, - /// Resource counters owned by this scan state. - pub resources: ScanResourceCounters, -} - -impl ScanState { - /// Creates scan state from visible segment paths. - #[must_use] - pub fn begin(table_oid: u32, visible_segments: Vec) -> Self { - let cold_streams_open = !visible_segments.is_empty(); - let object_store_handles = visible_segments.len(); - Self { - table_oid, - selected_row_groups: Vec::new(), - snapshot_captured: true, - cold_streams_open, - resources: ScanResourceCounters { - object_store_handles, - arrow_buffers: usize::from(cold_streams_open), - memory_context_bytes: 0, - }, - visible_segments, - } - } - - /// Releases cold stream handles. - pub fn cleanup(&mut self) { - self.cold_streams_open = false; - self.visible_segments.clear(); - self.selected_row_groups.clear(); - self.resources = ScanResourceCounters::default(); - } - - /// Reinitializes this scan state for PostgreSQL `Rescan`. - /// - /// # Errors - /// - /// Returns [`MergeScanError::ColdRequiredUnavailable`] when the new scan requires cold - /// segments but cold storage is unavailable. - pub fn rescan( - &mut self, - plan: &MergeScanPlan, - cold_availability: ColdAvailability, - ) -> Result<(), MergeScanError> { - self.cleanup(); - *self = begin_merge_scan_with_plan(plan, cold_availability)?; - Ok(()) - } -} - -/// Resources owned by a merge scan. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub struct ScanResourceCounters { - /// Object-store handles opened for cold segments. - pub object_store_handles: usize, - /// Arrow buffers currently retained by the scan. - pub arrow_buffers: usize, - /// Bytes allocated in the scan memory context. - pub memory_context_bytes: usize, -} - -/// Begins a merge scan with fail-closed cold availability checks. -/// -/// # Errors -/// -/// Returns [`MergeScanError::ColdRequiredUnavailable`] when visible cold segments exist but the -/// cold reader cannot be opened. -pub fn begin_merge_scan( - table_oid: u32, - visible_segments: Vec, - cold_availability: ColdAvailability, -) -> Result { - if !visible_segments.is_empty() && cold_availability == ColdAvailability::Unavailable { - return Err(MergeScanError::ColdRequiredUnavailable); - } - - Ok(ScanState::begin(table_oid, visible_segments)) -} - -/// Begins a merge scan from a serialized plan model. -/// -/// # Errors -/// -/// Returns [`MergeScanError::ColdRequiredUnavailable`] when cold segment hints exist but cold -/// storage is unavailable. -pub fn begin_merge_scan_with_plan( - plan: &MergeScanPlan, - cold_availability: ColdAvailability, -) -> Result { - let mut visible_segments = Vec::with_capacity(plan.segment_hints.len()); - let mut selected_row_groups = Vec::new(); - for hint in plan - .segment_hints - .iter() - .filter(|hint| segment_matches_scope(plan.scope_key.as_ref(), hint.scope_key.as_ref())) - { - visible_segments.push(hint.object_path.clone()); - selected_row_groups.extend(hint.selected_row_groups.iter().copied()); - } - - let mut state = begin_merge_scan(plan.table_oid, visible_segments, cold_availability)?; - state.selected_row_groups = selected_row_groups; - Ok(state) -} - -fn segment_matches_scope( - plan_scope: Option<&koldstore_common::ScopeKey>, - segment_scope: Option<&koldstore_common::ScopeKey>, -) -> bool { - match (plan_scope, segment_scope) { - (Some(plan_scope), Some(segment_scope)) => plan_scope == segment_scope, - (None, None) => true, - _ => false, - } -} - -/// Result of a logical merge-scan execution. -#[derive(Debug, Clone, PartialEq)] -pub struct MergeScanResult { - /// Visible logical rows after hot/cold winner resolution and tombstone masking. - pub rows: Vec, - /// Number of hot candidates observed. - pub hot_rows_seen: usize, - /// Number of cold candidates observed. - pub cold_rows_seen: usize, - /// Number of hot tombstones that participated in masking. - pub tombstones_masked: usize, - /// Rows filtered by residual predicates after winner resolution. - pub filtered_rows: usize, - /// Rows filtered by security predicates after winner resolution. - pub security_filtered_rows: usize, -} - -/// Executes the pure hot/cold merge resolution step used by the PostgreSQL CustomScan executor. -/// -/// # Errors -/// -/// Reserved for executor failures once this helper is wired to PostgreSQL tuple conversion and -/// cold-stream I/O. -pub fn execute_merge_scan( - hot_rows: Vec, - cold_rows: Vec, -) -> Result { - let tombstones_masked = hot_rows.iter().filter(|row| row.deleted).count(); - let hot_rows_seen = hot_rows.len(); - let cold_rows_seen = cold_rows.len(); - let rows = resolve_rows_owned(hot_rows, cold_rows); - - Ok(MergeScanResult { - rows, - hot_rows_seen, - cold_rows_seen, - tombstones_masked, - filtered_rows: 0, - security_filtered_rows: 0, - }) -} - -/// Simplified residual/security filter plan for pure executor tests. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct FilterPlan { - residual_eq: Vec<(String, String)>, - residual_in: Vec<(String, Vec)>, - security_eq: Vec<(String, String)>, -} - -impl FilterPlan { - /// Creates an empty filter plan. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Adds a residual JSON string equality filter. - #[must_use] - pub fn with_required_json_eq( - mut self, - column: impl Into, - value: impl Into, - ) -> Self { - self.residual_eq.push((column.into(), value.into())); - self - } - - /// Adds a residual JSON `IN (...)` membership filter. - #[must_use] - pub fn with_required_json_in(mut self, column: impl Into, values: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.residual_in - .push((column.into(), values.into_iter().map(Into::into).collect())); - self - } - - /// Adds a security JSON equality filter. - #[must_use] - pub fn with_security_json_eq(mut self, column: impl Into, value: i64) -> Self { - self.security_eq.push((column.into(), value.to_string())); - self - } -} - -/// Executes merge resolution and then applies residual/security filters. -/// -/// # Errors -/// -/// Reserved for PostgreSQL expression evaluation failures in the pgrx executor. -pub fn execute_merge_scan_with_filters( - hot_rows: Vec, - cold_rows: Vec, - filters: FilterPlan, -) -> Result { - let mut result = execute_merge_scan(hot_rows, cold_rows)?; - let before_residual = result.rows.len(); - result.rows.retain(|row| { - row_matches_eq(&row.row_image, &filters.residual_eq) - && row_matches_in(&row.row_image, &filters.residual_in) - }); - result.filtered_rows = before_residual.saturating_sub(result.rows.len()); - - let before_security = result.rows.len(); - result - .rows - .retain(|row| row_matches_eq(&row.row_image, &filters.security_eq)); - result.security_filtered_rows = before_security.saturating_sub(result.rows.len()); - - Ok(result) -} - -fn row_matches_eq(row: &koldstore_common::RowImage, filters: &[(String, String)]) -> bool { - filters.iter().all(|(column, expected)| { - row.get(column) - .is_some_and(|value| cell_matches_expected(value, expected)) - }) -} - -fn row_matches_in(row: &koldstore_common::RowImage, filters: &[(String, Vec)]) -> bool { - filters.iter().all(|(column, expected_values)| { - row.get(column).is_some_and(|value| { - expected_values - .iter() - .any(|expected| cell_matches_expected(value, expected)) - }) - }) -} - -fn cell_matches_expected(value: &koldstore_common::CellValue, expected: &str) -> bool { - match value { - koldstore_common::CellValue::Utf8(text) => text == expected, - other => other.display_text() == expected, - } -} diff --git a/crates/koldstore-merge/src/scan/mod.rs b/crates/koldstore-merge/src/scan/mod.rs index 3cc2042c..99963683 100644 --- a/crates/koldstore-merge/src/scan/mod.rs +++ b/crates/koldstore-merge/src/scan/mod.rs @@ -3,24 +3,21 @@ //! Owns PG-free merge-scan planning, path replacement, and hot/cold winner //! resolution helpers. PostgreSQL CustomScan FFI stays in `pg_koldstore`. -pub mod exec; pub mod ordered_frontier; pub mod ordered_merge; pub mod path; pub mod plan; +pub mod projection; pub mod strategy; /// Hot heap rows use a sentinel sequence during winner resolution so any live /// hot row beats every cold candidate for the same primary key. pub const HOT_SEQ_SENTINEL: i64 = i64::MAX; -pub use exec::{ - begin_merge_scan, begin_merge_scan_with_plan, execute_merge_scan, - execute_merge_scan_with_filters, ColdAvailability, FilterPlan, MergeScanError, MergeScanResult, - ScanResourceCounters, ScanState, -}; pub use ordered_frontier::{compare_hot_to_cold_bound, FrontierDecision, OrderDirection}; -pub use ordered_merge::{hot_keys_dominate_bound, select_competitive_row_groups}; +pub use ordered_merge::{ + hot_keys_dominate_bound, intersect_row_group_selections, select_competitive_row_groups, +}; pub use path::{ build_path_portfolio, clear_partial_heap_paths, custom_scan_explain_label, HotChildCandidate, PathPortfolioDecision, PlannerPath, PlannerPathKind, PortfolioPathEntry, CUSTOM_PATH_NAME, @@ -31,6 +28,7 @@ pub use plan::{ ColdPruneColumnPolicy, MergeMetadataAttnums, MergeScanPlan, MirrorOverlayStrategy, SegmentHint, SegmentPrunePredicate, SegmentStatsHint, }; +pub use projection::ColdProjectionPlan; pub use strategy::{ classify_path_strategy, KoldPathStrategy, OrderColumnSupport, OrderedPathSpec, StrategyRequest, }; diff --git a/crates/koldstore-merge/src/scan/ordered_merge.rs b/crates/koldstore-merge/src/scan/ordered_merge.rs index 00ad3d4b..5a046bbc 100644 --- a/crates/koldstore-merge/src/scan/ordered_merge.rs +++ b/crates/koldstore-merge/src/scan/ordered_merge.rs @@ -32,18 +32,21 @@ pub fn select_competitive_row_groups( row_group_mins: &[Option>], row_group_maxs: &[Option>], ) -> Vec { - let n = row_group_mins.len().min(row_group_maxs.len()); + // Catalog arrays should have identical cardinality, but incomplete metadata + // must fail open. Iterating the larger side preserves an unmatched row group + // and treats its missing directional bound as unknown/competitive. + let n = row_group_mins.len().max(row_group_maxs.len()); let Some(hot) = hot_key else { return (0..n).collect(); }; let mut selected = Vec::new(); for idx in 0..n { let competes = match direction { - OrderDirection::Asc => match row_group_mins[idx].as_deref() { + OrderDirection::Asc => match row_group_mins.get(idx).and_then(Option::as_deref) { None => true, Some(min_bound) => min_bound <= hot, }, - OrderDirection::Desc => match row_group_maxs[idx].as_deref() { + OrderDirection::Desc => match row_group_maxs.get(idx).and_then(Option::as_deref) { None => true, Some(max_bound) => max_bound >= hot, }, @@ -55,6 +58,24 @@ pub fn select_competitive_row_groups( selected } +/// Intersects catalog-planned row groups with an ordered-frontier selection. +/// +/// The planned order (and any duplicate entries) is preserved. Sorting the +/// owned competitive set once avoids the quadratic repeated-membership scan in +/// the PostgreSQL adapter without allocating a second lookup collection. +#[must_use] +pub fn intersect_row_group_selections( + planned: Vec, + mut competitive: Vec, +) -> Vec { + competitive.sort_unstable(); + competitive.dedup(); + planned + .into_iter() + .filter(|row_group| competitive.binary_search(row_group).is_ok()) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -114,4 +135,27 @@ mod tests { vec![0] ); } + + #[test] + fn mismatched_bound_arrays_keep_unmatched_row_groups_conservatively() { + let mins = [Some(b"a".to_vec())]; + let maxs = [Some(b"c".to_vec()), Some(b"z".to_vec())]; + + assert_eq!( + select_competitive_row_groups(OrderDirection::Asc, Some(b"d"), &mins, &maxs), + vec![0, 1] + ); + assert_eq!( + select_competitive_row_groups(OrderDirection::Desc, Some(b"d"), &mins, &maxs), + vec![1] + ); + } + + #[test] + fn row_group_intersection_preserves_planned_order() { + assert_eq!( + intersect_row_group_selections(vec![5, 2, 5, 9], vec![9, 3, 5]), + vec![5, 5, 9] + ); + } } diff --git a/crates/koldstore-merge/src/scan/projection.rs b/crates/koldstore-merge/src/scan/projection.rs new file mode 100644 index 00000000..b9034a41 --- /dev/null +++ b/crates/koldstore-merge/src/scan/projection.rs @@ -0,0 +1,119 @@ +//! Cold projection planning for ordered compete-then-body merge reads. + +use koldstore_common::ColumnRef; + +/// Narrow compete columns and deferred body columns for an ordered cold read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColdProjectionPlan { + compete: Vec, + body: Vec, +} + +impl ColdProjectionPlan { + /// Plans late materialization for one full projection. + /// + /// Returns `None` when compete already covers the full projection and a + /// second body open would add overhead without reducing column I/O. + #[must_use] + pub fn for_ordered( + full: &[ColumnRef], + primary_key: &[ColumnRef], + leading: &ColumnRef, + ) -> Option { + let mut compete = Vec::with_capacity(primary_key.len().saturating_add(1)); + compete.extend_from_slice(primary_key); + compete.push(leading.clone()); + compete.sort_by_key(|column| column.column_id); + compete.dedup_by_key(|column| column.column_id); + + let body = full + .iter() + .filter(|column| { + compete + .binary_search_by_key(&column.column_id, |candidate| candidate.column_id) + .is_err() + }) + .cloned() + .collect::>(); + + if body.is_empty() || compete.len() >= full.len() { + return None; + } + Some(Self { compete, body }) + } + + /// Order key plus primary-key columns used to select winners. + #[must_use] + pub fn compete(&self) -> &[ColumnRef] { + &self.compete + } + + /// Deferred application columns not needed for winner competition. + #[must_use] + pub fn body(&self) -> &[ColumnRef] { + &self.body + } + + /// Builds the body read projection with primary keys for winner hydration. + #[must_use] + pub fn body_with_primary_key(&self, primary_key: &[ColumnRef]) -> Vec { + let mut columns = self.body.clone(); + for pk in primary_key { + if !columns + .iter() + .any(|column| column.column_id == pk.column_id) + { + columns.push(pk.clone()); + } + } + columns + } +} + +#[cfg(test)] +mod tests { + use super::*; + use koldstore_common::ColumnId; + + fn column(id: i16, name: &str) -> ColumnRef { + ColumnRef::new(ColumnId::from_attnum(id), name) + } + + #[test] + fn ordered_projection_deduplicates_pk_and_defers_body_in_full_order() { + let id = column(1, "id"); + let tenant = column(2, "tenant_id"); + let created = column(3, "created_at"); + let body = column(4, "body"); + let status = column(5, "status"); + let full = vec![ + status.clone(), + id.clone(), + body.clone(), + created.clone(), + tenant.clone(), + ]; + + let plan = ColdProjectionPlan::for_ordered(&full, &[tenant.clone(), id.clone()], &created) + .expect("wide projection uses late materialization"); + + assert_eq!(plan.compete(), &[id.clone(), tenant.clone(), created]); + assert_eq!(plan.body(), &[status.clone(), body.clone()]); + assert_eq!( + plan.body_with_primary_key(&[tenant.clone(), id.clone()]), + vec![status, body, tenant, id] + ); + } + + #[test] + fn narrow_projection_uses_one_full_open() { + let id = column(1, "id"); + + assert!(ColdProjectionPlan::for_ordered( + std::slice::from_ref(&id), + std::slice::from_ref(&id), + &id, + ) + .is_none()); + } +} diff --git a/crates/koldstore-merge/tests/merge_scan_exec.rs b/crates/koldstore-merge/tests/merge_scan_exec.rs index e9d9ec3b..7d4a9af1 100644 --- a/crates/koldstore-merge/tests/merge_scan_exec.rs +++ b/crates/koldstore-merge/tests/merge_scan_exec.rs @@ -1,177 +1,10 @@ -use koldstore_common::{ColdRow, HotRow, LogicalPk, PkColumn, RowImage, ScopeKey, SeqId}; -use koldstore_merge::scan::exec::{ - begin_merge_scan, begin_merge_scan_with_plan, execute_merge_scan_with_filters, - ColdAvailability, FilterPlan, ScanResourceCounters, -}; +use koldstore_common::SeqId; use koldstore_merge::scan::plan::{ group_segments_newest_first, group_segments_oldest_first, retain_pre_merge_cold_prune_predicates, validate_prune_predicates_indexed, - ColdPruneColumnPolicy, MergeMetadataAttnums, MergeScanPlan, SegmentHint, SegmentPrunePredicate, - SegmentStatsHint, + ColdPruneColumnPolicy, SegmentPrunePredicate, SegmentStatsHint, }; use koldstore_sortkey::SortKeyValue; -use serde_json::json; - -fn pk(id: i64) -> LogicalPk { - LogicalPk::from_json_object(&json!({"id": id}), &[PkColumn::new("id").unwrap()]).unwrap() -} - -fn hot(id: i64, seq: i64, deleted: bool, status: &str) -> HotRow { - HotRow { - pk: pk(id), - scope_key: None, - seq: SeqId::new(seq).unwrap(), - deleted, - row_image: RowImage::from_json_value(json!({"id": id, "status": status})), - } -} - -fn cold(id: i64, seq: i64, status: &str) -> ColdRow { - ColdRow { - pk: pk(id), - scope_key: None, - seq: SeqId::new(seq).unwrap(), - deleted: false, - schema_version: 1, - row_image: RowImage::from_json_value(json!({"id": id, "status": status})), - } -} - -fn plan() -> MergeScanPlan { - MergeScanPlan { - table_oid: 42, - scanrelid: 1, - primary_key_columns: vec!["id".to_string()], - merge_metadata_attnums: MergeMetadataAttnums { - seq: 3, - deleted: 5, - scope: None, - }, - scope_key: None, - safe_quals: Vec::new(), - residual_quals: Vec::new(), - security_quals: Vec::new(), - projection: vec!["id".to_string(), "status".to_string()], - segment_hints: vec![SegmentHint { - segment_id: "segment-1".to_string(), - scope_key: None, - object_path: "app/items/batch-1.parquet".to_string(), - selected_row_groups: vec![1], - min_seq: SeqId::new(10).unwrap(), - max_seq: SeqId::new(30).unwrap(), - }], - overlay_strategy: Default::default(), - } -} - -#[test] -fn scoped_segments_are_filtered_before_cold_streams_open() { - let mut plan = plan(); - plan.scope_key = Some(ScopeKey::new("user-a").unwrap()); - plan.segment_hints = vec![ - SegmentHint { - segment_id: "segment-user-a".to_string(), - scope_key: Some(ScopeKey::new("user-a").unwrap()), - object_path: "app/items/user-a/batch-1.parquet".to_string(), - selected_row_groups: vec![0], - min_seq: SeqId::new(1).unwrap(), - max_seq: SeqId::new(10).unwrap(), - }, - SegmentHint { - segment_id: "segment-user-b".to_string(), - scope_key: Some(ScopeKey::new("user-b").unwrap()), - object_path: "app/items/user-b/batch-1.parquet".to_string(), - selected_row_groups: vec![1], - min_seq: SeqId::new(1).unwrap(), - max_seq: SeqId::new(10).unwrap(), - }, - SegmentHint { - segment_id: "segment-shared".to_string(), - scope_key: None, - object_path: "app/items/shared/batch-1.parquet".to_string(), - selected_row_groups: vec![2], - min_seq: SeqId::new(1).unwrap(), - max_seq: SeqId::new(10).unwrap(), - }, - ]; - - let state = begin_merge_scan_with_plan(&plan, ColdAvailability::Available).unwrap(); - - assert_eq!( - state.visible_segments, - vec!["app/items/user-a/batch-1.parquet"] - ); - assert_eq!(state.selected_row_groups, vec![0]); - assert_eq!(state.resources.object_store_handles, 1); -} - -#[test] -fn begin_merge_scan_loads_metadata_prunes_segments_and_opens_cold_streams() { - let state = begin_merge_scan_with_plan(&plan(), ColdAvailability::Available).unwrap(); - - assert_eq!(state.table_oid, 42); - assert_eq!(state.visible_segments, vec!["app/items/batch-1.parquet"]); - assert_eq!(state.selected_row_groups, vec![1]); - assert!(state.snapshot_captured); - assert!(state.cold_streams_open); - assert_eq!(state.resources.object_store_handles, 1); - assert_eq!(state.resources.arrow_buffers, 1); -} - -#[test] -fn direct_begin_merge_scan_tracks_each_cold_segment_handle() { - let state = begin_merge_scan( - 42, - vec![ - "app/items/batch-1.parquet".to_string(), - "app/items/batch-2.parquet".to_string(), - ], - ColdAvailability::Available, - ) - .unwrap(); - - assert_eq!(state.resources.object_store_handles, 2); - assert_eq!(state.resources.arrow_buffers, 1); -} - -#[test] -fn residual_and_security_quals_run_after_winner_resolution() { - let result = execute_merge_scan_with_filters( - vec![hot(1, 20, false, "open"), hot(2, 21, false, "closed")], - vec![cold(1, 10, "closed")], - FilterPlan::new() - .with_required_json_eq("status", "open") - .with_security_json_eq("id", 1), - ) - .unwrap(); - - assert_eq!(result.rows.len(), 1); - assert_eq!(result.rows[0].row_image["status"].as_str(), Some("open")); - assert_eq!(result.filtered_rows, 1); - assert_eq!(result.security_filtered_rows, 0); -} - -#[test] -fn scan_state_cleanup_releases_resources_and_rescan_resets_merge_state() { - let mut state = begin_merge_scan_with_plan(&plan(), ColdAvailability::Available).unwrap(); - state.resources = ScanResourceCounters { - object_store_handles: 1, - arrow_buffers: 2, - memory_context_bytes: 4096, - }; - - state.cleanup(); - - assert!(!state.cold_streams_open); - assert!(state.visible_segments.is_empty()); - assert!(state.selected_row_groups.is_empty()); - assert_eq!(state.resources, ScanResourceCounters::default()); - - state.rescan(&plan(), ColdAvailability::Available).unwrap(); - assert!(state.cold_streams_open); - assert_eq!(state.visible_segments, vec!["app/items/batch-1.parquet"]); -} - #[test] fn primary_key_predicates_are_retained_for_pre_merge_cold_prune() { let predicates = vec![ diff --git a/crates/koldstore-merge/tests/resolver.rs b/crates/koldstore-merge/tests/resolver.rs index 633c590e..e29eef96 100644 --- a/crates/koldstore-merge/tests/resolver.rs +++ b/crates/koldstore-merge/tests/resolver.rs @@ -3,8 +3,8 @@ use koldstore_common::{ ScopeKey, SeqId, }; use koldstore_merge::{ - changes_since, resolve_rows, tombstone_required, ChangeCursor, NewestFirstWinnerResolver, - TombstoneDecision, + changes_since, resolve_rows, resolve_rows_owned, tombstone_required, ChangeCursor, + MirrorOverlay, NewestFirstWinnerResolver, TombstoneDecision, }; use serde_json::json; @@ -75,6 +75,26 @@ fn resolver_selects_newest_row_per_pk_and_hot_wins_exact_tie() { assert_eq!(row2.row_image.to_json(), json!({"id": 2, "body": "hot-2"})); } +#[test] +fn borrowed_and_owned_resolution_produce_identical_winners() { + let hot_rows = vec![ + hot(1, 20, false, "hot-winner"), + hot(2, 30, true, "hot-delete"), + ]; + let cold_rows = vec![ + cold(1, 10, false, "cold-loser"), + cold(2, 20, false, "masked-by-delete"), + cold(3, 15, false, "cold-winner"), + ]; + + let mut borrowed = resolve_rows(&hot_rows, &cold_rows); + let mut owned = resolve_rows_owned(hot_rows, cold_rows); + borrowed.sort_by_key(|row| row.pk_json.to_string()); + owned.sort_by_key(|row| row.pk_json.to_string()); + + assert_eq!(borrowed, owned); +} + #[test] fn resolver_emits_at_most_one_visible_winner_per_pk() { let rows = resolve_rows( @@ -177,6 +197,29 @@ fn streaming_resolver_mirror_mask_applies_after_live_hot_winners() { assert!(cold_rows.is_empty()); } +#[test] +fn mirror_overlay_masks_cold_rows_without_replacing_the_batch_allocation() { + let overlay = MirrorOverlay::new([pk(2)]); + let mut rows = Vec::with_capacity(8); + rows.extend([ + cold(1, 10, false, "one"), + cold(2, 10, false, "masked"), + cold(3, 10, false, "three"), + ]); + let capacity = rows.capacity(); + + let removed = overlay.retain_unmasked(&mut rows); + + assert_eq!(removed, 1); + assert_eq!(rows.capacity(), capacity); + assert_eq!( + rows.iter() + .map(|row| row.row_image["id"].as_i64().unwrap()) + .collect::>(), + vec![1, 3] + ); +} + #[test] fn streaming_resolver_fails_closed_when_seen_key_limit_is_exceeded() { let mut resolver = NewestFirstWinnerResolver::default().with_max_seen_keys(Some(2)); diff --git a/crates/koldstore-migrate/src/validation/constraints.rs b/crates/koldstore-migrate/src/validation/constraints.rs index bdfe1956..7c4ee9b2 100644 --- a/crates/koldstore-migrate/src/validation/constraints.rs +++ b/crates/koldstore-migrate/src/validation/constraints.rs @@ -24,7 +24,7 @@ pub fn exact_primary_key_shape_supported(shape: &PrimaryKeyShape) -> bool { pub type ConstraintResult = Result; /// Migration validation error. -#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[derive(Debug, Clone, PartialEq, Error)] pub enum MigrationConstraintError { /// The table already has a managed schema registration. #[error("table is already managed")] @@ -37,6 +37,12 @@ pub enum MigrationConstraintError { /// Invalid value. value: i64, }, + /// A Parquet Bloom false-positive probability is outside its valid range. + #[error("parquet_bloom_filter_fpp must be greater than 0 and less than 1 (got {value})")] + InvalidParquetBloomFilterFpp { + /// Invalid probability. + value: f64, + }, /// The requested Parquet compression codec is unsupported. #[error("unsupported compression codec `{0}`")] UnsupportedCompression(String), diff --git a/crates/koldstore-migrate/src/validation/manage_table.rs b/crates/koldstore-migrate/src/validation/manage_table.rs index a0224d6c..e4ee0a37 100644 --- a/crates/koldstore-migrate/src/validation/manage_table.rs +++ b/crates/koldstore-migrate/src/validation/manage_table.rs @@ -4,14 +4,14 @@ //! them here before constructing migration plans. This module owns no SPI or //! PostgreSQL types. -use koldstore_common::{ManageTableOptions, ParquetCompression}; +use koldstore_common::{ManageTableOptions, ParquetBloomFilterFpp, ParquetCompression}; use super::constraints::{ ConstraintResult, MigrationConstraintError, MigrationValidation, MigrationValidationInput, }; /// Raw numeric policy values accepted by `manage_table`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct ManageTablePolicyInput { /// Maximum hot rows before automatic flush. pub hot_row_limit: Option, @@ -25,6 +25,12 @@ pub struct ManageTablePolicyInput { pub min_max_rows_per_file: u64, /// Whether the built-in scheduler may auto-flush this table. pub auto_flush: bool, + /// Optional row cap per Parquet row group. + pub parquet_row_group_size: Option, + /// Optional row cap per Parquet data page. + pub parquet_data_page_row_count_limit: Option, + /// Optional Parquet Bloom filter false-positive probability. + pub parquet_bloom_filter_fpp: Option, } /// Catalog-resolved segment ordering column accepted at the manage boundary. @@ -48,7 +54,7 @@ pub struct ScopeColumnInput { } /// PostgreSQL-free context required to validate one `manage_table` call. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct ManageTableValidationContext<'a> { /// Catalog-derived migration shape and constraint policy. pub migration: MigrationValidationInput, @@ -79,6 +85,19 @@ pub struct ValidatedManageTable { pub migration: MigrationValidation, } +/// Resolves the cold segment-order column for a managed table. +/// +/// An explicit segment-order column takes precedence. Otherwise the stable +/// migration ordering column also orders cold segments, so ordered reads can +/// use the progressive merge path without repeating the same configuration. +#[must_use] +pub fn effective_segment_order_column<'a>( + segment_order_column: Option<&'a str>, + migration_order_by: Option<&'a str>, +) -> Option<&'a str> { + segment_order_column.or(migration_order_by) +} + /// Validates operator-provided policy values without requiring catalog access. /// /// PostgreSQL callers use this before provisioning WAL capture so malformed @@ -107,6 +126,16 @@ pub fn validate_manage_table_preflight( if let Some(target_file_size_mb) = policy.target_file_size_mb { positive_value(target_file_size_mb, "target_file_size_mb")?; } + if let Some(row_group_size) = policy.parquet_row_group_size { + positive_value(row_group_size, "parquet_row_group_size")?; + } + if let Some(page_limit) = policy.parquet_data_page_row_count_limit { + positive_value(page_limit, "parquet_data_page_row_count_limit")?; + } + if let Some(fpp) = policy.parquet_bloom_filter_fpp { + ParquetBloomFilterFpp::new(fpp) + .map_err(|_| MigrationConstraintError::InvalidParquetBloomFilterFpp { value: fpp })?; + } Ok(()) } @@ -192,6 +221,21 @@ pub fn validate_manage_table( options = options .with_target_file_size_mb(positive_value(target_file_size_mb, "target_file_size_mb")?); } + if let Some(row_group_size) = context.policy.parquet_row_group_size { + options = options + .with_parquet_row_group_size(positive_value(row_group_size, "parquet_row_group_size")?); + } + if let Some(page_limit) = context.policy.parquet_data_page_row_count_limit { + options = options.with_parquet_data_page_row_count_limit(positive_value( + page_limit, + "parquet_data_page_row_count_limit", + )?); + } + if let Some(fpp) = context.policy.parquet_bloom_filter_fpp { + let fpp = ParquetBloomFilterFpp::new(fpp) + .map_err(|_| MigrationConstraintError::InvalidParquetBloomFilterFpp { value: fpp })?; + options = options.with_parquet_bloom_filter_fpp(fpp); + } if let Some(columns) = context.pruning_columns { options = options.with_pruning_columns(columns.iter().cloned()); } diff --git a/crates/koldstore-migrate/src/workflow/backfill.rs b/crates/koldstore-migrate/src/workflow/backfill.rs index 80985cdd..4105371c 100644 --- a/crates/koldstore-migrate/src/workflow/backfill.rs +++ b/crates/koldstore-migrate/src/workflow/backfill.rs @@ -71,6 +71,34 @@ pub fn plan_mirror_initialization_batch( primary_key: &[PrimaryKeyColumnShape], ordering: MigrationOrdering, batch_size: MigrationBatchSize, +) -> MirrorInitializationResult { + plan_mirror_initialization_batch_with_segment_order( + table, + mirror_table, + primary_key, + ordering, + batch_size, + None, + ) +} + +/// Plans one bounded mirror-initialization batch with an optional cold order key. +/// +/// When cold segments have an immutable order column, existing rows must carry +/// the same encoded `order_key` as later WAL-applied rows. This keeps initial +/// migration and post-manage writes interchangeable to the flush pipeline. +/// +/// # Errors +/// +/// Returns an error when the primary key or either configured order identifier +/// is invalid, or when the generated SQL cannot be represented by SPI. +pub fn plan_mirror_initialization_batch_with_segment_order( + table: &QualifiedTableName, + mirror_table: &QualifiedTableName, + primary_key: &[PrimaryKeyColumnShape], + ordering: MigrationOrdering, + batch_size: MigrationBatchSize, + segment_order_column: Option<&str>, ) -> MirrorInitializationResult { if primary_key.is_empty() { return Err(MirrorInitializationError::MissingPrimaryKey); @@ -80,6 +108,11 @@ pub fn plan_mirror_initialization_batch( ordering.column, )); } + if let Some(column) = segment_order_column.filter(|column| !is_safe_identifier(column)) { + return Err(MirrorInitializationError::InvalidIdentifier( + column.to_string(), + )); + } let primary_key_names: Vec<&str> = primary_key .iter() @@ -104,9 +137,18 @@ pub fn plan_mirror_initialization_batch( let order_column = quote_ident(&ordering.column); let order_column_ref = format!("hot.{order_column}"); let mut insert_columns = pk_columns.clone(); + if segment_order_column.is_some() { + insert_columns.push("\"order_key\"".to_string()); + } insert_columns.extend(MirrorColumn::insert_quoted_names()); let mut select_columns = pk_columns.clone(); + if segment_order_column.is_some() { + select_columns.push("koldstore.internal_encode_sort_key(segment_order_value)".to_string()); + } select_columns.extend([snowflake_id_call_expression().to_string(), "1".to_string()]); + let segment_order_projection = segment_order_column.map_or_else(String::new, |column| { + format!(", hot.{} AS segment_order_value", quote_ident(column)) + }); let order_direction = if ordering.ascending_oldest_first { "ASC" } else { @@ -115,7 +157,7 @@ pub fn plan_mirror_initialization_batch( let sql = format!( r#" WITH candidate AS MATERIALIZED ( - SELECT {hot_pk_columns}, {order_column_ref} AS migration_order_value, hot.ctid AS hot_ctid + SELECT {hot_pk_columns}, {order_column_ref} AS migration_order_value{segment_order_projection}, hot.ctid AS hot_ctid FROM ONLY {table} AS hot LEFT JOIN {mirror} AS mirror ON {join_predicate} @@ -140,6 +182,8 @@ SELECT mirror = mirror_table.quoted(), join_predicate = join_predicate, mirror_missing_predicate = mirror_missing_predicate, + order_column_ref = order_column_ref, + segment_order_projection = segment_order_projection, insert_columns = insert_columns.join(", "), select_columns = select_columns.join(", "), conflict_columns = pk_columns.join(", "), diff --git a/crates/koldstore-migrate/tests/change_log_mirror_dml.rs b/crates/koldstore-migrate/tests/change_log_mirror_dml.rs index 9732e0e0..dbfa3e67 100644 --- a/crates/koldstore-migrate/tests/change_log_mirror_dml.rs +++ b/crates/koldstore-migrate/tests/change_log_mirror_dml.rs @@ -49,7 +49,7 @@ fn change_log_mirror_installs_pk_guard_only() { ); assert!(guard.trigger.sql.contains("$koldstore_drop_trigger$")); assert!(guard.trigger.sql.contains( - "EXECUTE 'DROP TRIGGER \"messages__cl_pk_update_guard\" ON \"public\".\"messages\"'" + "EXECUTE 'DROP TRIGGER \"public_messages__cl_pk_update_guard\" ON \"public\".\"messages\"'" )); } diff --git a/crates/koldstore-migrate/tests/clean_schema_enable.rs b/crates/koldstore-migrate/tests/clean_schema_enable.rs index d607d64b..50091e04 100644 --- a/crates/koldstore-migrate/tests/clean_schema_enable.rs +++ b/crates/koldstore-migrate/tests/clean_schema_enable.rs @@ -33,7 +33,9 @@ fn clean_schema_enablement_plans_no_user_table_system_columns() { ] .join("\n"); - assert!(planned_sql.contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"messages__cl\"")); + assert!( + planned_sql.contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"public_messages__cl\"") + ); assert!(planned_sql.contains("\"id\" bigint NOT NULL")); assert!(planned_sql.contains("\"seq\" bigint NOT NULL")); assert!(planned_sql.contains("\"op\" smallint NOT NULL")); diff --git a/crates/koldstore-migrate/tests/manage_table_validation.rs b/crates/koldstore-migrate/tests/manage_table_validation.rs index db4865c3..c55602b8 100644 --- a/crates/koldstore-migrate/tests/manage_table_validation.rs +++ b/crates/koldstore-migrate/tests/manage_table_validation.rs @@ -3,8 +3,9 @@ use koldstore_migrate::constraints::{ MigrationValidationInput, UniqueConstraintShape, }; use koldstore_migrate::manage_table::{ - validate_manage_table, validate_manage_table_preflight, ManageTablePolicyInput, - ManageTableValidationContext, ScopeColumnInput, SegmentOrderColumnInput, + effective_segment_order_column, validate_manage_table, validate_manage_table_preflight, + ManageTablePolicyInput, ManageTableValidationContext, ScopeColumnInput, + SegmentOrderColumnInput, }; fn valid_context() -> ManageTableValidationContext<'static> { @@ -22,6 +23,9 @@ fn valid_context() -> ManageTableValidationContext<'static> { target_file_size_mb: None, min_max_rows_per_file: 1_000, auto_flush: true, + parquet_row_group_size: None, + parquet_data_page_row_count_limit: None, + parquet_bloom_filter_fpp: None, }, pruning_columns: None, bloom_filter_columns: None, @@ -61,6 +65,22 @@ fn segment_order_column_is_validated_and_persisted_by_attnum() { assert_eq!(validated.options.segment_order_column_id, Some(4)); } +#[test] +fn migration_order_column_becomes_the_default_segment_order_column() { + assert_eq!( + effective_segment_order_column(None, Some("created_at")), + Some("created_at") + ); +} + +#[test] +fn explicit_segment_order_column_overrides_the_migration_order_column() { + assert_eq!( + effective_segment_order_column(Some("event_time"), Some("created_at")), + Some("event_time") + ); +} + #[test] fn scope_column_is_persisted_by_attnum() { let mut context = valid_context(); diff --git a/crates/koldstore-migrate/tests/migrate_existing.rs b/crates/koldstore-migrate/tests/migrate_existing.rs index f7304e41..d8792de0 100644 --- a/crates/koldstore-migrate/tests/migrate_existing.rs +++ b/crates/koldstore-migrate/tests/migrate_existing.rs @@ -3,7 +3,9 @@ use koldstore_common::{ }; use koldstore_common::{SqlAccess as SpiAccess, SqlParamType}; use koldstore_migrate::{ - backfill::plan_mirror_initialization_batch, + backfill::{ + plan_mirror_initialization_batch, plan_mirror_initialization_batch_with_segment_order, + }, constraints::{ColumnDefinition, IndexDefinition, MigrationValidationInput}, jobs::MigrationBatchSize, order::{MigrationOrdering, OrderingSource}, @@ -123,3 +125,35 @@ fn existing_row_initialization_uses_skip_locked_batches_not_table_wide_update() assert!(!plan.statement.sql.contains(forbidden)); } } + +#[test] +fn existing_row_initialization_populates_the_segment_order_key() { + let table = QualifiedTableName::parse("app.items").unwrap(); + let mirror = QualifiedTableName::parse("koldstore.items__cl").unwrap(); + let plan = plan_mirror_initialization_batch_with_segment_order( + &table, + &mirror, + &pk(), + MigrationOrdering { + column: "created_at".to_string(), + source: OrderingSource::ExplicitColumn, + ascending_oldest_first: true, + }, + MigrationBatchSize::new(1_000).unwrap(), + Some("created_at"), + ) + .unwrap(); + + assert!(plan + .statement + .sql + .contains("\"order_key\", \"seq\", \"op\"")); + assert!(plan + .statement + .sql + .contains("hot.\"created_at\" AS segment_order_value")); + assert!(plan + .statement + .sql + .contains("koldstore.internal_encode_sort_key(segment_order_value)")); +} diff --git a/crates/koldstore-migrate/tests/system_columns.rs b/crates/koldstore-migrate/tests/system_columns.rs index 8b2cd4be..4b970bb4 100644 --- a/crates/koldstore-migrate/tests/system_columns.rs +++ b/crates/koldstore-migrate/tests/system_columns.rs @@ -23,7 +23,7 @@ fn clean_schema_migration_uses_mirror_table_instead_of_system_columns() { assert!(plan .create_table .sql - .contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"items__cl\"")); + .contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"app_items__cl\"")); for forbidden in [ "\"_seq\"", "\"_commit_seq\"", diff --git a/crates/koldstore-parquet/src/lib.rs b/crates/koldstore-parquet/src/lib.rs index ed511925..5440fcbe 100644 --- a/crates/koldstore-parquet/src/lib.rs +++ b/crates/koldstore-parquet/src/lib.rs @@ -21,7 +21,7 @@ pub use footer::{ pub use footer_cache as parquet_footer_cache; pub use koldstore_common::{canonical_postgres_type_name, CellValue}; pub use koldstore_schema::{PgIntegerArrayOid, PgType, SchemaError}; -pub use object_reader::{ObjectStoreParquetReader, ObjectStoreReadStats}; +pub use object_reader::{ObjectStoreParquetReader, ObjectStoreReadSnapshot, ObjectStoreReadStats}; pub use page_prune::{row_selection_for_equality_values, PagePruneDecision}; pub use pg_type_codec::{ arrow_array_for_column, arrow_array_from_json, arrow_data_type, cell_from_arrow_cell, @@ -32,8 +32,8 @@ pub use reader::{ clean_cold_row_to_common, read_clean_cold_rows_from_object_store, read_clean_cold_rows_from_object_store_async, read_clean_cold_rows_from_object_store_with_size, read_clean_cold_rows_from_object_store_with_stats, read_clean_cold_rows_with_options, - BloomPruneMode, CleanColdRow, PageIndexPruneMode, ParquetReadOptions, ParquetReadProfile, - ParquetReadRequest, + BloomPruneMode, CleanColdRow, PageIndexPruneMode, ParquetProfileMode, ParquetReadOptions, + ParquetReadProfile, ParquetReadRequest, }; pub use schema::{build_clean_arrow_schema, ColdMetadataColumn, PgColumn}; pub use writer::{ diff --git a/crates/koldstore-parquet/src/object_reader.rs b/crates/koldstore-parquet/src/object_reader.rs index fc293e3d..fda52ab5 100644 --- a/crates/koldstore-parquet/src/object_reader.rs +++ b/crates/koldstore-parquet/src/object_reader.rs @@ -7,6 +7,7 @@ use std::ops::Range; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use bytes::Bytes; use futures_util::future::BoxFuture; @@ -18,23 +19,71 @@ use parquet::arrow::async_reader::{AsyncFileReader, MetadataSuffixFetch}; use parquet::errors::{ParquetError, Result as ParquetResult}; use parquet::file::metadata::{PageIndexPolicy, ParquetMetaData, ParquetMetaDataReader}; -/// Optional I/O counters for proving range-only ObjectStore reads in tests. +/// Optional I/O counters and wait timing for EXPLAIN diagnostics and tests. #[derive(Debug, Default)] pub struct ObjectStoreReadStats { /// Number of `get_range` / `get_ranges` / suffix `get_opts` calls. pub range_calls: AtomicU64, /// Total bytes returned by those range calls. pub bytes_read: AtomicU64, + /// Wall time awaiting successful object-store range reads, in nanoseconds. + read_nanos: AtomicU64, + /// Whether callers requested wall-clock timing in addition to counters. + timing_enabled: bool, +} + +/// Point-in-time object-store I/O counters for one Parquet reader. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ObjectStoreReadSnapshot { + /// Successful range/suffix calls completed so far. + pub range_calls: u64, + /// Bytes returned by successful range/suffix calls. + pub bytes_read: u64, + /// Wall time spent awaiting successful range/suffix calls. + pub read_duration: Duration, } impl ObjectStoreReadStats { - /// Snapshot of `(range_calls, bytes_read)`. + /// Creates counters that also measure object-store wait time. + #[must_use] + pub fn with_timing() -> Self { + Self { + timing_enabled: true, + ..Self::default() + } + } + + /// Snapshot of `(range_calls, bytes_read)` for compatibility with existing callers. #[must_use] pub fn snapshot(&self) -> (u64, u64) { - ( - self.range_calls.load(Ordering::SeqCst), - self.bytes_read.load(Ordering::SeqCst), - ) + let snapshot = self.timed_snapshot(); + (snapshot.range_calls, snapshot.bytes_read) + } + + /// Returns completed-read counters plus accumulated object-store wait time. + #[must_use] + pub fn timed_snapshot(&self) -> ObjectStoreReadSnapshot { + ObjectStoreReadSnapshot { + range_calls: self.range_calls.load(Ordering::Relaxed), + bytes_read: self.bytes_read.load(Ordering::Relaxed), + read_duration: Duration::from_nanos(self.read_nanos.load(Ordering::Relaxed)), + } + } + + fn start_timer(&self) -> Option { + self.timing_enabled.then(Instant::now) + } + + fn record_read(&self, bytes: usize, started: Option) { + self.range_calls.fetch_add(1, Ordering::Relaxed); + self.bytes_read + .fetch_add(u64::try_from(bytes).unwrap_or(u64::MAX), Ordering::Relaxed); + if let Some(started) = started { + let elapsed = started.elapsed(); + let elapsed_nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + // Wrapping is fine: u64 nanos overflow after ~584 years. + self.read_nanos.fetch_add(elapsed_nanos, Ordering::Relaxed); + } } } @@ -99,15 +148,13 @@ impl AsyncFileReader for ObjectStoreParquetReader { let path = self.path.clone(); let stats = self.stats.clone(); async move { + let started = stats.as_ref().and_then(|stats| stats.start_timer()); let bytes = store .get_range(&path, range) .await .map_err(|error| ParquetError::External(Box::new(error)))?; if let Some(stats) = stats { - stats.range_calls.fetch_add(1, Ordering::SeqCst); - stats - .bytes_read - .fetch_add(bytes.len() as u64, Ordering::SeqCst); + stats.record_read(bytes.len(), started); } Ok(bytes) } @@ -122,14 +169,14 @@ impl AsyncFileReader for ObjectStoreParquetReader { let path = self.path.clone(); let stats = self.stats.clone(); async move { + let started = stats.as_ref().and_then(|stats| stats.start_timer()); let parts = store .get_ranges(&path, &ranges) .await .map_err(|error| ParquetError::External(Box::new(error)))?; if let Some(stats) = stats { - stats.range_calls.fetch_add(1, Ordering::SeqCst); - let total: u64 = parts.iter().map(|b| b.len() as u64).sum(); - stats.bytes_read.fetch_add(total, Ordering::SeqCst); + let total = parts.iter().map(bytes::Bytes::len).sum(); + stats.record_read(total, started); } Ok(parts) } @@ -192,6 +239,7 @@ impl MetadataSuffixFetch for &mut ObjectStoreParquetReader { let path = self.path.clone(); let stats = self.stats.clone(); async move { + let started = stats.as_ref().and_then(|stats| stats.start_timer()); let options = GetOptions { range: Some(GetRange::Suffix(suffix as u64)), ..Default::default() @@ -205,13 +253,45 @@ impl MetadataSuffixFetch for &mut ObjectStoreParquetReader { .await .map_err(|error| ParquetError::External(Box::new(error)))?; if let Some(stats) = stats { - stats.range_calls.fetch_add(1, Ordering::SeqCst); - stats - .bytes_read - .fetch_add(bytes.len() as u64, Ordering::SeqCst); + stats.record_read(bytes.len(), started); } Ok(bytes) } .boxed() } } + +#[cfg(test)] +mod tests { + use super::ObjectStoreReadStats; + use std::time::Duration; + + #[test] + fn object_store_read_stats_do_not_measure_wait_time_by_default() { + let stats = ObjectStoreReadStats::default(); + + stats.record_read(512, stats.start_timer()); + stats.record_read(256, stats.start_timer()); + + let snapshot = stats.timed_snapshot(); + assert_eq!(snapshot.range_calls, 2); + assert_eq!(snapshot.bytes_read, 768); + assert_eq!(snapshot.read_duration, Duration::ZERO); + } + + #[test] + fn object_store_read_stats_measure_wait_time_when_requested() { + let stats = ObjectStoreReadStats::with_timing(); + + let started = stats.start_timer().expect("timed stats must start a clock"); + while started.elapsed().is_zero() { + std::hint::spin_loop(); + } + stats.record_read(512, Some(started)); + + let snapshot = stats.timed_snapshot(); + assert_eq!(snapshot.range_calls, 1); + assert_eq!(snapshot.bytes_read, 512); + assert!(snapshot.read_duration > Duration::ZERO); + } +} diff --git a/crates/koldstore-parquet/src/reader/mod.rs b/crates/koldstore-parquet/src/reader/mod.rs index 1ba86819..dc952e7e 100644 --- a/crates/koldstore-parquet/src/reader/mod.rs +++ b/crates/koldstore-parquet/src/reader/mod.rs @@ -19,6 +19,7 @@ pub use object_store::{ read_clean_cold_rows_from_object_store_with_stats, }; pub use options::{ - BloomPruneMode, PageIndexPruneMode, ParquetReadOptions, ParquetReadProfile, PkValues, SeqRange, + BloomPruneMode, PageIndexPruneMode, ParquetProfileMode, ParquetReadOptions, ParquetReadProfile, + PkValues, SeqRange, }; pub use types::{CleanColdRow, ParquetReadRequest}; diff --git a/crates/koldstore-parquet/src/reader/object_store.rs b/crates/koldstore-parquet/src/reader/object_store.rs index 60baf5ec..1e2a0a3a 100644 --- a/crates/koldstore-parquet/src/reader/object_store.rs +++ b/crates/koldstore-parquet/src/reader/object_store.rs @@ -1,6 +1,7 @@ //! ObjectStore-backed Parquet cold reads (footer-first, range GETs). use std::sync::Arc; +use std::time::Instant; use futures_util::StreamExt; use parquet::arrow::arrow_reader::ArrowReaderOptions; @@ -15,7 +16,9 @@ use crate::prune::{ use crate::schema::PgColumn; use super::decode::{application_columns_for_read, clean_rows_from_batch, projection_mask}; -use super::options::{BloomPruneMode, ParquetReadOptions, ParquetReadProfile, PkValues}; +use super::options::{ + BloomPruneMode, ParquetProfileMode, ParquetReadOptions, ParquetReadProfile, PkValues, +}; use super::types::CleanColdRow; /// Reads clean-schema cold rows via ObjectStore range requests. @@ -53,7 +56,8 @@ pub fn read_clean_cold_rows_from_object_store( /// request (important for S3 backends that do not support suffix ranges). /// /// Returns `(rows, profile)` so callers can surface footer/bloom/I/O details in -/// EXPLAIN and tracing. +/// EXPLAIN and tracing. The profile is empty unless `options.profile_mode` +/// explicitly enables diagnostic collection. /// /// # Errors /// @@ -67,12 +71,12 @@ pub fn read_clean_cold_rows_from_object_store_with_size( primary_key_columns: &[String], options: &ParquetReadOptions, ) -> Result<(Vec, ParquetReadProfile), String> { - let io = Arc::new(crate::object_reader::ObjectStoreReadStats::default()); + let io = read_stats_for(options.profile_mode); read_clean_cold_rows_from_object_store_with_stats( store, object_path, file_size, - Some(io), + io, columns, primary_key_columns, options, @@ -133,14 +137,19 @@ pub async fn read_clean_cold_rows_from_object_store_async( primary_key_columns: &[String], options: &ParquetReadOptions, ) -> Result<(Vec, ParquetReadProfile), String> { - let io = - stats.unwrap_or_else(|| Arc::new(crate::object_reader::ObjectStoreReadStats::default())); - let footer_cache_hit = crate::footer_cache::get(object_path, file_size).is_some(); + let collect_profile = options.profile_mode.collects_counts(); + let collect_timing = options.profile_mode.collects_timing(); + let io = stats.or_else(|| read_stats_for(options.profile_mode)); + let open_started = collect_timing.then(Instant::now); + let footer_cache_hit = + collect_profile && crate::footer_cache::get(object_path, file_size).is_some(); let mut reader = ObjectStoreParquetReader::from_key(store, object_path)?; if let Some(size) = file_size { reader = reader.with_file_size(size); } - reader = reader.with_stats(Arc::clone(&io)); + if let Some(io) = &io { + reader = reader.with_stats(Arc::clone(io)); + } // Load page indexes only for equality probes; other paths keep the lighter // Skip footer (and remain eligible for the footer cache). let reader_options = if options.pk_values.is_some() { @@ -151,6 +160,10 @@ pub async fn read_clean_cold_rows_from_object_store_async( let mut builder = ParquetRecordBatchStreamBuilder::new_with_options(reader, reader_options) .await .map_err(|error| error.to_string())?; + let open_duration = open_started + .map(|started| started.elapsed()) + .unwrap_or_default(); + let scan_started = collect_timing.then(Instant::now); let application_columns = application_columns_for_read(columns, primary_key_columns, options)?; @@ -200,7 +213,13 @@ pub async fn read_clean_cold_rows_from_object_store_async( }; stats_pruned |= stats_selected.len() < selected_row_groups.len(); if stats_selected.is_empty() { - let (range_calls, bytes_read) = io.snapshot(); + if !collect_profile { + return Ok((Vec::new(), ParquetReadProfile::default())); + } + let io_snapshot = io + .as_ref() + .map(|stats| stats.timed_snapshot()) + .unwrap_or_default(); return Ok(( Vec::new(), empty_read_profile(ParquetReadProfile { @@ -211,9 +230,14 @@ pub async fn read_clean_cold_rows_from_object_store_async( bloom: bloom_mode, projected_columns: application_columns, pk_probe: Some((pk.column.clone(), pk.values.clone())), - range_calls, - bytes_read, + range_calls: io_snapshot.range_calls, + bytes_read: io_snapshot.bytes_read, footer_cache_hit, + open_duration, + scan_duration: scan_started + .map(|started| started.elapsed()) + .unwrap_or_default(), + object_store_read_duration: io_snapshot.read_duration, ..Default::default() }), )); @@ -243,7 +267,13 @@ pub async fn read_clean_cold_rows_from_object_store_async( if pruning_applied { if selected_row_groups.is_empty() { - let (range_calls, bytes_read) = io.snapshot(); + if !collect_profile { + return Ok((Vec::new(), ParquetReadProfile::default())); + } + let io_snapshot = io + .as_ref() + .map(|stats| stats.timed_snapshot()) + .unwrap_or_default(); return Ok(( Vec::new(), empty_read_profile(ParquetReadProfile { @@ -262,9 +292,14 @@ pub async fn read_clean_cold_rows_from_object_store_async( .pk_values .as_ref() .map(|pk| (pk.column.clone(), pk.values.clone())), - range_calls, - bytes_read, + range_calls: io_snapshot.range_calls, + bytes_read: io_snapshot.bytes_read, footer_cache_hit, + open_duration, + scan_duration: scan_started + .map(|started| started.elapsed()) + .unwrap_or_default(), + object_store_read_duration: io_snapshot.read_duration, ..Default::default() }), )); @@ -311,12 +346,18 @@ pub async fn read_clean_cold_rows_from_object_store_async( } } + if !collect_profile { + return Ok((rows, ParquetReadProfile::default())); + } let selected = if pruning_applied { selected_row_groups } else { (0..total_row_groups).collect() }; - let (range_calls, bytes_read) = io.snapshot(); + let io_snapshot = io + .as_ref() + .map(|stats| stats.timed_snapshot()) + .unwrap_or_default(); let profile = ParquetReadProfile { object_path: object_path.to_string(), file_size, @@ -336,14 +377,31 @@ pub async fn read_clean_cold_rows_from_object_store_async( .pk_values .as_ref() .map(|pk| (pk.column.clone(), pk.values.clone())), - range_calls, - bytes_read, + range_calls: io_snapshot.range_calls, + bytes_read: io_snapshot.bytes_read, rows_returned: rows.len(), footer_cache_hit, + open_duration, + scan_duration: scan_started + .map(|started| started.elapsed()) + .unwrap_or_default(), + object_store_read_duration: io_snapshot.read_duration, }; Ok((rows, profile)) } +fn read_stats_for( + mode: ParquetProfileMode, +) -> Option> { + mode.collects_counts().then(|| { + Arc::new(if mode.collects_timing() { + crate::object_reader::ObjectStoreReadStats::with_timing() + } else { + crate::object_reader::ObjectStoreReadStats::default() + }) + }) +} + /// Builds a zero-row profile after prune eliminated every row group. fn empty_read_profile(base: ParquetReadProfile) -> ParquetReadProfile { let total = base.row_groups_total; diff --git a/crates/koldstore-parquet/src/reader/options.rs b/crates/koldstore-parquet/src/reader/options.rs index f43aa72e..1e1a7719 100644 --- a/crates/koldstore-parquet/src/reader/options.rs +++ b/crates/koldstore-parquet/src/reader/options.rs @@ -15,6 +15,8 @@ pub struct ParquetReadOptions { pub row_limit: Option, /// Outer wall-clock budget for one segment open/read (`None` = disabled). pub timeout: Option, + /// Diagnostic work requested by the caller. + pub profile_mode: ParquetProfileMode, } impl ParquetReadOptions { @@ -106,6 +108,35 @@ impl ParquetReadOptions { self.timeout = timeout.filter(|value| !value.is_zero()); self } + + /// Selects whether read counters and wall-clock timings are collected. + #[must_use] + pub fn with_profile_mode(mut self, mode: ParquetProfileMode) -> Self { + self.profile_mode = mode; + self + } +} + +/// Per-read diagnostic collection requested by a caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ParquetProfileMode { + /// Skip diagnostic counters, clocks, and profile-owned allocations. + #[default] + Disabled, + /// Collect counters and pruning details without reading the wall clock. + Counts, + /// Collect counters, pruning details, and phase timings. + CountsAndTiming, +} + +impl ParquetProfileMode { + pub(crate) const fn collects_counts(self) -> bool { + !matches!(self, Self::Disabled) + } + + pub(crate) const fn collects_timing(self) -> bool { + matches!(self, Self::CountsAndTiming) + } } /// Sequence range pruning. @@ -178,6 +209,15 @@ pub struct ParquetReadProfile { pub rows_returned: usize, /// Footer metadata served from the backend-local cache (no footer GET). pub footer_cache_hit: bool, + /// Wall time to construct the Parquet reader and load footer metadata. + pub open_duration: Duration, + /// Wall time after footer load through row-group scan and row decoding. + pub scan_duration: Duration, + /// Wall time awaited inside successful object-store range/suffix reads. + /// + /// This is a subset of `open_duration + scan_duration`, not an additive + /// phase, because footer and column reads occur within those two phases. + pub object_store_read_duration: Duration, } impl BloomPruneMode { @@ -261,3 +301,18 @@ impl ParquetReadProfile { } } } + +#[cfg(test)] +mod tests { + use super::ParquetProfileMode; + + #[test] + fn parquet_profile_modes_separate_regular_counts_and_timed_reads() { + assert!(!ParquetProfileMode::Disabled.collects_counts()); + assert!(!ParquetProfileMode::Disabled.collects_timing()); + assert!(ParquetProfileMode::Counts.collects_counts()); + assert!(!ParquetProfileMode::Counts.collects_timing()); + assert!(ParquetProfileMode::CountsAndTiming.collects_counts()); + assert!(ParquetProfileMode::CountsAndTiming.collects_timing()); + } +} diff --git a/crates/koldstore-parquet/tests/reader_pruning.rs b/crates/koldstore-parquet/tests/reader_pruning.rs index 5002e2e1..b0f7bdb4 100644 --- a/crates/koldstore-parquet/tests/reader_pruning.rs +++ b/crates/koldstore-parquet/tests/reader_pruning.rs @@ -328,8 +328,8 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { use arrow_array::{BooleanArray, Int16Array, Int64Array, RecordBatch, UInt32Array}; use arrow_schema::{DataType, Field, Schema}; use koldstore_parquet::{ - read_clean_cold_rows_from_object_store_with_size, BloomPruneMode, ParquetSegmentWriter, - PgColumn, PgType, WriterOptions, + read_clean_cold_rows_from_object_store_with_size, BloomPruneMode, ParquetProfileMode, + ParquetSegmentWriter, PgColumn, PgType, WriterOptions, }; use koldstore_storage::{ObjectStoreClient, StorageClient}; @@ -379,7 +379,8 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { &["id".to_string()], &ParquetReadOptions::new() .with_columns(["id"]) - .with_pk_values("id", ["4"]), + .with_pk_values("id", ["4"]) + .with_profile_mode(ParquetProfileMode::Counts), ) .unwrap(); @@ -405,7 +406,9 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { Some(file_size), &[PgColumn::new("id", PgType::Int8, false)], &["id".to_string()], - &ParquetReadOptions::new().with_columns(["id"]), + &ParquetReadOptions::new() + .with_columns(["id"]) + .with_profile_mode(ParquetProfileMode::Counts), ) .unwrap(); assert!(!profile_full.footer_cache_hit); @@ -416,7 +419,9 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { Some(file_size), &[PgColumn::new("id", PgType::Int8, false)], &["id".to_string()], - &ParquetReadOptions::new().with_columns(["id"]), + &ParquetReadOptions::new() + .with_columns(["id"]) + .with_profile_mode(ParquetProfileMode::Counts), ) .unwrap(); assert!( @@ -434,7 +439,8 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { &ParquetReadOptions::new() .with_columns(["id"]) .with_row_groups([0, 1, 2]) - .with_pk_values("id", ["7"]), + .with_pk_values("id", ["7"]) + .with_profile_mode(ParquetProfileMode::Counts), ) .unwrap(); assert!(missing_rows.is_empty()); @@ -445,6 +451,20 @@ fn object_store_read_profile_reports_footer_first_and_bloom_skip() { ); assert_eq!(missing_profile.row_groups_skipped, 3); assert!(missing_profile.stats_pruned); + + let (unprofiled_rows, unprofiled) = read_clean_cold_rows_from_object_store_with_size( + client.store(), + key, + Some(file_size), + &[PgColumn::new("id", PgType::Int8, false)], + &["id".to_string()], + &ParquetReadOptions::new() + .with_columns(["id"]) + .with_profile_mode(ParquetProfileMode::Disabled), + ) + .unwrap(); + assert_eq!(unprofiled_rows.len(), 6); + assert_eq!(unprofiled, Default::default()); } #[test] @@ -454,8 +474,8 @@ fn object_store_pk_probe_applies_page_index_row_selection() { use arrow_array::{BooleanArray, Int16Array, Int64Array, RecordBatch, UInt32Array}; use arrow_schema::{DataType, Field, Schema}; use koldstore_parquet::{ - read_clean_cold_rows_from_object_store_with_size, PageIndexPruneMode, ParquetSegmentWriter, - PgColumn, PgType, WriterOptions, + read_clean_cold_rows_from_object_store_with_size, PageIndexPruneMode, ParquetProfileMode, + ParquetSegmentWriter, PgColumn, PgType, WriterOptions, }; use koldstore_storage::{ObjectStoreClient, StorageClient}; @@ -514,7 +534,8 @@ fn object_store_pk_probe_applies_page_index_row_selection() { &["id".to_string()], &ParquetReadOptions::new() .with_columns(["id"]) - .with_pk_values("id", ["50"]), + .with_pk_values("id", ["50"]) + .with_profile_mode(ParquetProfileMode::Counts), ) .unwrap(); diff --git a/crates/koldstore-storage/src/backend/fs.rs b/crates/koldstore-storage/src/backend/fs.rs index b7e9dba6..af122f3a 100644 --- a/crates/koldstore-storage/src/backend/fs.rs +++ b/crates/koldstore-storage/src/backend/fs.rs @@ -60,9 +60,68 @@ pub fn ensure_filesystem_base_prepared(base_path: &str) -> StorageResult true`) so KoldStore does +/// not silently share a directory that already has cold objects or other data. +/// Callers that intentionally reuse a non-empty root must pass `check => false`. +/// +/// # Errors +/// +/// Returns [`StorageClientError::InvalidPath`] when the directory cannot be +/// listed or is not empty. +pub fn ensure_filesystem_base_empty(base_path: &str) -> StorageResult<()> { + let root = parse_filesystem_root(base_path)?; + let entries = std::fs::read_dir(&root).map_err(|error| StorageClientError::InvalidPath { + message: format!( + "cannot list filesystem base_path `{base_path}` (resolved {}): {error}", + root.display() + ), + })?; + + let mut samples = Vec::new(); + let mut total = 0usize; + for entry in entries { + let entry = entry.map_err(|error| StorageClientError::InvalidPath { + message: format!( + "cannot list filesystem base_path `{base_path}` (resolved {}): {error}", + root.display() + ), + })?; + total += 1; + if samples.len() < NONEMPTY_SAMPLE_LIMIT { + samples.push(entry.file_name().to_string_lossy().into_owned()); + } + } + + if total == 0 { + return Ok(()); + } + + let sample = samples.join(", "); + let more = if total > samples.len() { + format!(", … ({} total entries)", total) + } else { + format!(" ({} total entries)", total) + }; + Err(StorageClientError::InvalidPath { + message: format!( + "filesystem base_path `{base_path}` (resolved {}) is not empty{more}: found [{sample}]. \ + Registering storage against a non-empty directory risks mixing unrelated files with \ + KoldStore cold objects. Choose an empty directory, or pass check => false to \ + register_storage / alter_storage_location if you intentionally reuse this path \ + (writability probe and emptiness check are both skipped when check is false).", + root.display() + ), + }) +} + #[cfg(test)] mod tests { - use super::ensure_filesystem_base_prepared; + use super::{ensure_filesystem_base_empty, ensure_filesystem_base_prepared}; #[test] fn ensure_filesystem_base_prepared_creates_dir() { @@ -71,4 +130,34 @@ mod tests { let resolved = ensure_filesystem_base_prepared(path.to_str().unwrap()).expect("prepared"); assert!(resolved.is_dir()); } + + #[test] + fn ensure_filesystem_base_empty_accepts_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cold"); + ensure_filesystem_base_prepared(path.to_str().unwrap()).expect("prepared"); + ensure_filesystem_base_empty(path.to_str().unwrap()).expect("empty"); + } + + #[test] + fn ensure_filesystem_base_empty_rejects_nonempty_dir() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cold"); + ensure_filesystem_base_prepared(path.to_str().unwrap()).expect("prepared"); + std::fs::write(path.join("orphan.parquet"), b"x").expect("write"); + let err = ensure_filesystem_base_empty(path.to_str().unwrap()).expect_err("non-empty"); + let message = err.to_string(); + assert!( + message.contains("is not empty"), + "expected emptiness failure, got: {message}" + ); + assert!( + message.contains("orphan.parquet"), + "expected sample entry in message, got: {message}" + ); + assert!( + message.contains("check => false"), + "expected bypass hint, got: {message}" + ); + } } diff --git a/crates/koldstore-storage/src/backend/mod.rs b/crates/koldstore-storage/src/backend/mod.rs index fe5b2277..762a2446 100644 --- a/crates/koldstore-storage/src/backend/mod.rs +++ b/crates/koldstore-storage/src/backend/mod.rs @@ -10,7 +10,7 @@ mod s3; mod util; pub use config::BackendConfig; -pub use fs::ensure_filesystem_base_prepared; +pub use fs::{ensure_filesystem_base_empty, ensure_filesystem_base_prepared}; pub use kind::StorageBackendKind; pub use open::{ ensure_storage_backend_writable, open_client_from_catalog_fields, diff --git a/crates/koldstore-storage/src/backend/open.rs b/crates/koldstore-storage/src/backend/open.rs index cbcb9db5..e1722147 100644 --- a/crates/koldstore-storage/src/backend/open.rs +++ b/crates/koldstore-storage/src/backend/open.rs @@ -8,7 +8,9 @@ use crate::client::{ use super::azure::open_azure_client; use super::config::BackendConfig; -use super::fs::{ensure_filesystem_base_prepared, parse_filesystem_root}; +use super::fs::{ + ensure_filesystem_base_empty, ensure_filesystem_base_prepared, parse_filesystem_root, +}; use super::gcs::open_gcs_client; use super::kind::StorageBackendKind; use super::s3::open_s3_client; @@ -115,14 +117,19 @@ pub fn open_filesystem_client(base_path: impl AsRef) -> StorageResult true` -/// (default). For filesystem backends, creates `base_path` first. For every -/// backend, writes then deletes [`STORAGE_WRITE_PROBE_KEY`] through the same -/// `object_store` client path flush will use. +/// (default). For filesystem backends, creates `base_path` when needed and +/// fails when the directory is already non-empty. For every backend, writes +/// then deletes [`STORAGE_WRITE_PROBE_KEY`] through the same `object_store` +/// client path flush will use. +/// +/// Pass `check => false` from SQL to skip both the emptiness requirement and +/// the writability probe. /// /// # Errors /// -/// Returns an error when the client cannot be constructed or the probe write / -/// delete fails (permissions, credentials, network, missing bucket, …). +/// Returns an error when the filesystem root is non-empty, the client cannot +/// be constructed, or the probe write / delete fails (permissions, credentials, +/// network, missing bucket, …). pub fn ensure_storage_backend_writable( storage_type: &str, base_path: &str, @@ -133,6 +140,7 @@ pub fn ensure_storage_backend_writable( .map_err(|message| StorageClientError::InvalidPath { message })?; if kind == StorageBackendKind::Filesystem { ensure_filesystem_base_prepared(base_path)?; + ensure_filesystem_base_empty(base_path)?; } let client = open_client_from_catalog_fields(storage_type, base_path, credentials, config) @@ -182,4 +190,23 @@ mod tests { let client = open_filesystem_client(path.to_str().unwrap()).unwrap(); assert!(client.head(STORAGE_WRITE_PROBE_KEY).is_err()); } + + #[test] + fn ensure_storage_backend_writable_rejects_nonempty_filesystem_root() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cold"); + std::fs::create_dir_all(&path).expect("mkdir"); + std::fs::write(path.join("existing"), b"data").expect("write"); + let err = ensure_storage_backend_writable( + "filesystem", + path.to_str().unwrap(), + &serde_json::json!({}), + &serde_json::json!({}), + ) + .expect_err("non-empty filesystem root must fail check"); + let message = err.to_string(); + assert!(message.contains("is not empty"), "got: {message}"); + assert!(message.contains("check => false"), "got: {message}"); + assert!(message.contains("existing"), "got: {message}"); + } } diff --git a/crates/koldstore-storage/src/lib.rs b/crates/koldstore-storage/src/lib.rs index 8db545c1..8b339331 100644 --- a/crates/koldstore-storage/src/lib.rs +++ b/crates/koldstore-storage/src/lib.rs @@ -21,7 +21,7 @@ pub mod registration; pub mod runtime; pub use backend::{ - ensure_filesystem_base_prepared, ensure_storage_backend_writable, + ensure_filesystem_base_empty, ensure_filesystem_base_prepared, ensure_storage_backend_writable, open_client_from_catalog_fields, open_client_from_catalog_fields_with_timeout, open_filesystem_client, open_storage_client, open_storage_client_with_timeout, BackendConfig, StorageBackendKind, STORAGE_WRITE_PROBE_KEY, diff --git a/crates/koldstore-wal-mirror/src/lib.rs b/crates/koldstore-wal-mirror/src/lib.rs index b25c2ad0..1bf75566 100644 --- a/crates/koldstore-wal-mirror/src/lib.rs +++ b/crates/koldstore-wal-mirror/src/lib.rs @@ -19,20 +19,22 @@ pub use mirror::{ statement, write, }; pub use mirror::{ - decode_message, mirror_relation_for_source, must_flush_before_push, parse_pk_bool, - parse_pk_ints, pg_value_json, pg_value_text, pk_identity, + decode_message, mirror_relation_for_source, mirror_seq_index_name, mirror_tombstone_index_name, + must_flush_before_push, order_column_text, parse_pk_bool, parse_pk_ints, pg_value_text, + pk_column_indexes, pk_guard_trigger_name, pk_identity, pk_type_oids, plan_async_mirror_batch_delete_existing, plan_async_mirror_batch_update, plan_async_mirror_batch_upsert, plan_drop_mirror_table, plan_mirror_force_flush_stats, plan_mirror_oldest_rows_max_seq, plan_mirror_op_stats, plan_mirror_pk_column_renames, - plan_mirror_pk_guard, plan_mirror_schema, plan_mirror_schema_with_order_key, - plan_mirror_source_teardown, plan_mirror_stats, plan_select_mirror_last_rows, - plan_select_mirror_last_rows_with_params, plan_select_mirror_rows_after_seq, - plan_select_mirror_rows_after_seq_with_params, plan_upsert_mirror_row, primary_key_json, - published_column_list, quoted_pk_columns, BatchFlushReason, MirrorColumn, MirrorError, - MirrorGuardError, MirrorGuardResult, MirrorPkGuardPlan, MirrorRelation, MirrorResult, - MirrorSchemaPlan, MirrorSeqStats, PgOutputColumn, PgOutputDecodeError, PgOutputMessage, - PgOutputRelation, PgOutputTuple, PgOutputValue, SqlAccess, SqlParamType, SqlStatement, - APPLY_BATCH_ROWS, CHANGE_LOG_MIRROR_SUFFIX, KOLDSTORE_SCHEMA, + plan_mirror_pk_guard, plan_mirror_relation_rename, plan_mirror_schema, + plan_mirror_schema_with_order_key, plan_mirror_source_teardown, plan_mirror_stats, + plan_select_mirror_last_rows, plan_select_mirror_last_rows_with_params, + plan_select_mirror_rows_after_seq, plan_select_mirror_rows_after_seq_with_params, + plan_upsert_mirror_row, primary_key_cells, published_column_list, quoted_pk_columns, + take_pk_cells_and_order_text, BatchFlushReason, MirrorColumn, MirrorError, MirrorGuardError, + MirrorGuardResult, MirrorPkGuardPlan, MirrorRelation, MirrorResult, MirrorSchemaPlan, + MirrorSeqStats, PgOutputColumn, PgOutputDecodeError, PgOutputMessage, PgOutputRelation, + PgOutputTuple, PgOutputValue, PkBindColumn, PkCell, PkIdentity, SqlAccess, SqlParamType, + SqlStatement, APPLY_BATCH_ROWS, CHANGE_LOG_MIRROR_SUFFIX, KOLDSTORE_SCHEMA, }; pub use wal::apply_contract::{ budget_hit, resolve_row_budget, resolve_time_budget, BoundedApplyOutcome, BoundedApplyRequest, diff --git a/crates/koldstore-wal-mirror/src/mirror/async/apply_row.rs b/crates/koldstore-wal-mirror/src/mirror/async/apply_row.rs index a11aa9d4..ad9a9493 100644 --- a/crates/koldstore-wal-mirror/src/mirror/async/apply_row.rs +++ b/crates/koldstore-wal-mirror/src/mirror/async/apply_row.rs @@ -1,44 +1,227 @@ -//! Pure helpers that map decoded `pgoutput` tuples into mirror batch row JSON. +//! Pure helpers that map decoded `pgoutput` tuples into typed bind columns. //! -//! SPI execution and managed-relation lookup stay in `pg_koldstore`. - -use serde_json::{Map, Value}; +//! Apply batches stay columnar (native int/bool arrays or text, `seq`, optional +//! `order_key` bytes). JSON is not part of this path. In-batch identity is a +//! typed [`PkIdentity`]: a single builtin int/bool is an inline key, so the +//! common `bigint` PK path never builds a String or hashes heap bytes. SPI +//! execution and managed-relation lookup stay in `pg_koldstore`. use super::pgoutput::{PgOutputRelation, PgOutputTuple, PgOutputValue}; -/// Compact PK identity for in-batch dedupe (ordered values, NUL-separated). -#[must_use] -pub fn pk_identity(row: &Map) -> String { - let mut identity = String::new(); - for (key, value) in row { - if key == "seq" { - continue; +/// PostgreSQL `bool` type OID. +pub const BOOLOID: u32 = 16; +/// PostgreSQL `int2` type OID. +pub const INT2OID: u32 = 21; +/// PostgreSQL `int4` type OID. +pub const INT4OID: u32 = 23; +/// PostgreSQL `int8` type OID. +pub const INT8OID: u32 = 20; + +/// One primary-key column's values ready for a typed SPI `unnest` bind. +/// +/// Builtin int/bool OIDs parse once here. Domains, uuid, and other scalars stay +/// text so SQL can still cast (`pk_array_bind_kind` TextThenCast). +#[derive(Debug, Clone, PartialEq)] +pub enum PkBindColumn { + /// `bigint` / `int8`. + Int8(Vec), + /// `integer` / `int4`. + Int4(Vec), + /// `smallint` / `int2`. + Int2(Vec), + /// `boolean`. + Bool(Vec), + /// Text-format cells, including types bound as `text[]` then cast. + Text(Vec), +} + +impl PkBindColumn { + /// Allocates an empty column batch for `type_oid`. + #[must_use] + pub fn with_capacity(type_oid: u32, cap: usize) -> Self { + match type_oid { + INT8OID => Self::Int8(Vec::with_capacity(cap)), + INT4OID => Self::Int4(Vec::with_capacity(cap)), + INT2OID => Self::Int2(Vec::with_capacity(cap)), + BOOLOID => Self::Bool(Vec::with_capacity(cap)), + _ => Self::Text(Vec::with_capacity(cap)), + } + } + + /// Appends one already-parsed PK cell. Builtin ints/bools are not reparsed. + /// + /// # Errors + /// + /// Returns an error when `cell` does not match this column's bind type. + pub fn push_cell(&mut self, cell: PkCell, column: &str) -> Result<(), String> { + match (&mut *self, cell) { + (Self::Int8(values), PkCell::Int8(value)) => values.push(value), + (Self::Int4(values), PkCell::Int4(value)) => values.push(value), + (Self::Int2(values), PkCell::Int2(value)) => values.push(value), + (Self::Bool(values), PkCell::Bool(value)) => values.push(value), + (Self::Text(values), PkCell::Text(value)) => values.push(value), + (expected, got) => { + return Err(format!( + "async mirror PK bind mismatch for {column}: expected {}, got {}", + bind_kind(expected), + cell_kind(&got) + )); + } } - if !identity.is_empty() { - identity.push('\0'); + Ok(()) + } + + /// Appends one pgoutput text cell, parsing native scalars immediately. + /// + /// # Errors + /// + /// Returns an error when the cell cannot be parsed as the column's type. + pub fn push_text(&mut self, cell: String, column: &str) -> Result<(), String> { + let type_oid = match self { + Self::Int8(_) => INT8OID, + Self::Int4(_) => INT4OID, + Self::Int2(_) => INT2OID, + Self::Bool(_) => BOOLOID, + Self::Text(_) => 0, + }; + self.push_cell(PkCell::from_pg_text(type_oid, cell, column)?, column) + } + + /// Number of staged values. + #[must_use] + pub fn len(&self) -> usize { + match self { + Self::Int8(values) => values.len(), + Self::Int4(values) => values.len(), + Self::Int2(values) => values.len(), + Self::Bool(values) => values.len(), + Self::Text(values) => values.len(), } - match value { - Value::String(text) => identity.push_str(text), - other => identity.push_str(&other.to_string()), + } + + /// Returns true when no values are staged. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// One parsed primary-key scalar taken from a pgoutput tuple. +/// +/// Builtin int/bool OIDs parse at extract. Everything else stays owned text. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PkCell { + /// `bigint` / `int8`. + Int8(i64), + /// `integer` / `int4`. + Int4(i32), + /// `smallint` / `int2`. + Int2(i16), + /// `boolean`. + Bool(bool), + /// Text-format cell, including uuid/domain/other scalars. + Text(String), +} + +impl PkCell { + /// Parses one pgoutput text cell using the column's type OID. + /// + /// # Errors + /// + /// Returns an error when a builtin int/bool cell cannot be parsed. + pub fn from_pg_text(type_oid: u32, text: String, column: &str) -> Result { + match type_oid { + INT8OID => Ok(Self::Int8(text.parse::().map_err(|error| { + format!("async mirror PK int8 value `{text}` for {column}: {error}") + })?)), + INT4OID => Ok(Self::Int4(text.parse::().map_err(|error| { + format!("async mirror PK int4 value `{text}` for {column}: {error}") + })?)), + INT2OID => Ok(Self::Int2(text.parse::().map_err(|error| { + format!("async mirror PK int2 value `{text}` for {column}: {error}") + })?)), + BOOLOID => Ok(Self::Bool(parse_pk_bool(&text)?)), + _ => Ok(Self::Text(text)), } } - identity } -/// Builds a primary-key JSON object from a decoded `pgoutput` tuple. +/// In-batch PK identity. Single builtin scalars are inline; text/composite heap. /// -/// Uses linear column lookup (typical PK width is tiny) to avoid per-row -/// `HashMap` allocation on the apply hot path. +/// Callers must pass only primary-key cells. `seq` and `order_key` are not part +/// of identity: latest-state batches flush on PK collision, not on payload. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PkIdentity { + /// Single `bigint` key. The common managed-table path; no heap. + Int8(i64), + /// Single `integer` key. + Int4(i32), + /// Single `smallint` key. + Int2(i16), + /// Single `boolean` key. + Bool(bool), + /// Single non-builtin scalar (uuid, domain, text, …). + Text(Box), + /// Composite primary key. Boxed so HashSet keys stay small. + Composite(Box<[PkCell]>), +} + +impl PkIdentity { + /// Builds identity from catalog-ordered PK cells. + /// + /// A single builtin int/bool is stored inline. A single text cell is one + /// `Box`. Two or more cells are a boxed slice so HashSet keys stay small. + #[must_use] + pub fn from_cells(cells: &[PkCell]) -> Self { + match cells { + [PkCell::Int8(value)] => Self::Int8(*value), + [PkCell::Int4(value)] => Self::Int4(*value), + [PkCell::Int2(value)] => Self::Int2(*value), + [PkCell::Bool(value)] => Self::Bool(*value), + [PkCell::Text(value)] => Self::Text(value.clone().into_boxed_str()), + other => Self::Composite(other.to_vec().into_boxed_slice()), + } + } +} + +/// Compact PK identity for in-batch dedupe. +/// +/// Equivalent to [`PkIdentity::from_cells`]. +#[must_use] +pub fn pk_identity(pk_cells: &[PkCell]) -> PkIdentity { + PkIdentity::from_cells(pk_cells) +} + +fn bind_kind(column: &PkBindColumn) -> &'static str { + match column { + PkBindColumn::Int8(_) => "int8", + PkBindColumn::Int4(_) => "int4", + PkBindColumn::Int2(_) => "int2", + PkBindColumn::Bool(_) => "bool", + PkBindColumn::Text(_) => "text", + } +} + +fn cell_kind(cell: &PkCell) -> &'static str { + match cell { + PkCell::Int8(_) => "int8", + PkCell::Int4(_) => "int4", + PkCell::Int2(_) => "int2", + PkCell::Bool(_) => "bool", + PkCell::Text(_) => "text", + } +} + +/// Resolves managed PK names to relation-tuple indexes. /// /// # Errors /// /// Returns an error when a managed primary-key column is missing from the -/// relation, omitted from the tuple, NULL, or emitted as unchanged TOAST. -pub fn primary_key_json( +/// relation. +pub fn pk_column_indexes( relation: &PgOutputRelation, primary_key: &[String], - tuple: &PgOutputTuple, -) -> Result, String> { +) -> Result, String> { let mut key_columns = Vec::with_capacity(primary_key.len()); for key in primary_key { let relation_index = relation @@ -53,9 +236,52 @@ pub fn primary_key_json( })?; key_columns.push(relation_index); } + Ok(key_columns) +} + +/// Type OIDs for `key_columns` in catalog PK order. +/// +/// # Errors +/// +/// Returns an error when an index is out of range for the relation. +pub fn pk_type_oids( + relation: &PgOutputRelation, + key_columns: &[usize], +) -> Result, String> { + key_columns + .iter() + .map(|&index| { + relation + .columns + .get(index) + .map(|column| column.type_oid) + .ok_or_else(|| format!("primary-key column index {index} is out of range")) + }) + .collect() +} + +/// Extracts ordered primary-key cells from a decoded `pgoutput` tuple. +/// +/// Compact old-key tuples (PK columns only) are read in catalog order. +/// Builtin int/bool cells parse here; the tuple text is dropped. +/// +/// # Errors +/// +/// Returns an error when a managed primary-key column is omitted from the +/// tuple, NULL, emitted as unchanged TOAST, or fails native parse. +pub fn primary_key_cells( + relation: &PgOutputRelation, + primary_key: &[String], + key_columns: &[usize], + type_oids: &[u32], + tuple: &mut PgOutputTuple, +) -> Result, String> { + if key_columns.len() != primary_key.len() || type_oids.len() != primary_key.len() { + return Err("primary-key index count does not match column names".to_string()); + } let compact_old_key = tuple.values.len() == key_columns.len() && tuple.values.len() != relation.columns.len(); - let mut row = Map::with_capacity(primary_key.len()); + let mut cells = Vec::with_capacity(primary_key.len()); for (key_position, key) in primary_key.iter().enumerate() { let relation_index = key_columns[key_position]; let tuple_index = if compact_old_key { @@ -65,40 +291,100 @@ pub fn primary_key_json( }; let value = tuple .values - .get(tuple_index) + .get_mut(tuple_index) .ok_or_else(|| format!("tuple omits primary-key column {key}"))?; - row.insert(key.clone(), pg_value_json(value, key)?); + let text = take_pg_value_text(value, key, "primary-key")?; + cells.push(PkCell::from_pg_text(type_oids[key_position], text, key)?); } - Ok(row) + Ok(cells) +} + +/// Takes PK cells and, when requested, the segment-order column's pgoutput text. +/// +/// The order column is peeked **before** PK cells are taken. Taking replaces PK +/// tuple slots with NULL, and `migration_order_by` is often the PK itself (`id`). +/// +/// # Errors +/// +/// Returns an error when PK extract fails or the order column is missing, NULL, +/// unchanged TOAST, or binary. +pub fn take_pk_cells_and_order_text( + relation: &PgOutputRelation, + primary_key: &[String], + key_columns: &[usize], + type_oids: &[u32], + order_column: Option<&str>, + tuple: &mut PgOutputTuple, +) -> Result<(Vec, Option), String> { + let order_text = match order_column { + Some(name) => Some(order_column_text(relation, name, tuple)?), + None => None, + }; + let cells = primary_key_cells(relation, primary_key, key_columns, type_oids, tuple)?; + Ok((cells, order_text)) +} + +/// Reads one published column as pgoutput text without taking it. +/// +/// # Errors +/// +/// Returns an error when the column is unpublished, omitted, NULL, unchanged +/// TOAST, or binary. +pub fn order_column_text( + relation: &PgOutputRelation, + column: &str, + tuple: &PgOutputTuple, +) -> Result { + let relation_index = relation + .columns + .iter() + .position(|candidate| candidate.name == column) + .ok_or_else(|| { + format!( + "pgoutput relation {}.{} does not publish segment order column {column}", + relation.namespace, relation.name + ) + })?; + let value = tuple + .values + .get(relation_index) + .ok_or_else(|| format!("tuple omits segment order column {column}"))?; + pg_value_text(value, column, "segment order") } -/// Converts one `pgoutput` value into a UTF-8 text cell. +/// Converts one `pgoutput` value into a UTF-8 text cell without taking it. /// /// # Errors /// -/// Returns an error for NULL, unchanged TOAST, binary, or non-UTF8 text. +/// Returns an error for NULL, unchanged TOAST, or binary. pub fn pg_value_text(value: &PgOutputValue, column: &str, role: &str) -> Result { match value { PgOutputValue::Null => Err(format!("{role} column {column} is NULL")), PgOutputValue::UnchangedToast => Err(format!( "{role} column {column} was emitted as unchanged TOAST" )), - PgOutputValue::Text(bytes) => std::str::from_utf8(bytes) - .map(str::to_string) - .map_err(|error| error.to_string()), + PgOutputValue::Text(text) => Ok(text.clone()), PgOutputValue::Binary(_) => { Err(format!("{role} column {column} arrived as binary pgoutput")) } } } -/// Converts one `pgoutput` value into a JSON cell for mirror batch apply. -/// -/// # Errors -/// -/// Returns an error for NULL, unchanged TOAST, binary, or non-UTF8 text. -pub fn pg_value_json(value: &PgOutputValue, column: &str) -> Result { - Ok(Value::String(pg_value_text(value, column, "primary-key")?)) +fn take_pg_value_text( + value: &mut PgOutputValue, + column: &str, + role: &str, +) -> Result { + match std::mem::replace(value, PgOutputValue::Null) { + PgOutputValue::Text(text) => Ok(text), + other => { + let error = pg_value_text(&other, column, role) + .err() + .unwrap_or_else(|| format!("{role} column {column} is missing")); + *value = other; + Err(error) + } + } } /// Parses primary-key text cells into typed integers for SPI array binds. @@ -126,57 +412,151 @@ where /// /// Returns an error when the cell is not a recognized boolean literal. pub fn parse_pk_bool(cell: &str) -> Result { - match cell.trim().to_ascii_lowercase().as_str() { - "t" | "true" | "1" | "yes" | "on" => Ok(true), - "f" | "false" | "0" | "no" | "off" => Ok(false), - other => Err(format!("async mirror PK boolean value `{other}`")), + let cell = cell.trim(); + if cell.eq_ignore_ascii_case("t") + || cell.eq_ignore_ascii_case("true") + || cell == "1" + || cell.eq_ignore_ascii_case("yes") + || cell.eq_ignore_ascii_case("on") + { + return Ok(true); + } + if cell.eq_ignore_ascii_case("f") + || cell.eq_ignore_ascii_case("false") + || cell == "0" + || cell.eq_ignore_ascii_case("no") + || cell.eq_ignore_ascii_case("off") + { + return Ok(false); } + Err(format!("async mirror PK boolean value `{cell}`")) } #[cfg(test)] mod tests { - use super::{parse_pk_bool, parse_pk_ints, pk_identity, primary_key_json}; + use super::{ + order_column_text, parse_pk_bool, parse_pk_ints, pk_column_indexes, pk_identity, + pk_type_oids, primary_key_cells, take_pk_cells_and_order_text, PkBindColumn, PkCell, + PkIdentity, INT8OID, + }; use crate::mirror::r#async::pgoutput::{ PgOutputColumn, PgOutputRelation, PgOutputTuple, PgOutputValue, }; - use serde_json::{json, Map}; + use std::collections::HashSet; + use std::mem::size_of; - #[test] - fn pk_identity_skips_seq_and_joins_values() { - let mut row = Map::new(); - row.insert("id".into(), json!("a")); - row.insert("seq".into(), json!(9)); - row.insert("tenant".into(), json!("t1")); - assert_eq!(pk_identity(&row), "a\0t1"); + fn relation(columns: &[(&str, bool)]) -> PgOutputRelation { + relation_with_oids(columns.iter().map(|(name, key)| (*name, *key, 25))) } - #[test] - fn primary_key_json_reads_compact_old_tuple() { - let relation = PgOutputRelation { + fn relation_with_oids<'a>( + columns: impl IntoIterator, + ) -> PgOutputRelation { + PgOutputRelation { id: 1, namespace: "public".into(), name: "items".into(), replica_identity: b'd', - columns: vec![ - PgOutputColumn { - key: true, - name: "id".into(), - type_oid: 20, + columns: columns + .into_iter() + .map(|(name, key, type_oid)| PgOutputColumn { + key, + name: name.into(), + type_oid, typmod: -1, - }, - PgOutputColumn { - key: false, - name: "body".into(), - type_oid: 25, - typmod: -1, - }, - ], - }; - let tuple = PgOutputTuple { - values: vec![PgOutputValue::Text(b"42".to_vec())], - }; - let row = primary_key_json(&relation, &["id".into()], &tuple).unwrap(); - assert_eq!(row.get("id"), Some(&json!("42"))); + }) + .collect(), + } + } + + fn text_tuple(cells: &[&str]) -> PgOutputTuple { + PgOutputTuple { + values: cells + .iter() + .map(|cell| PgOutputValue::Text((*cell).to_string())) + .collect(), + } + } + + fn extract_cells( + relation: &PgOutputRelation, + primary_key: &[String], + tuple: &mut PgOutputTuple, + ) -> Vec { + let keys = pk_column_indexes(relation, primary_key).unwrap(); + let type_oids = pk_type_oids(relation, &keys).unwrap(); + primary_key_cells(relation, primary_key, &keys, &type_oids, tuple).unwrap() + } + + #[test] + fn pk_identity_int8_is_inline_copy_key() { + assert_eq!( + PkIdentity::from_cells(&[PkCell::Int8(42)]), + PkIdentity::Int8(42) + ); + assert!( + size_of::() <= 24, + "PkIdentity is {} bytes; box text/composite so HashSet keys stay small", + size_of::() + ); + let mut seen = HashSet::new(); + assert!(seen.insert(PkIdentity::Int8(42))); + assert!(!seen.insert(PkIdentity::Int8(42))); + assert!(seen.insert(PkIdentity::Int8(43))); + } + + #[test] + fn pk_identity_keeps_composite_cells_distinct() { + let left = pk_identity(&[PkCell::Text("a".into()), PkCell::Text("t1".into())]); + let right = pk_identity(&[PkCell::Text("at1".into())]); + assert_ne!(left, right); + assert_eq!( + left, + PkIdentity::Composite(vec![PkCell::Text("a".into()), PkCell::Text("t1".into())].into()) + ); + assert_eq!(right, PkIdentity::Text("at1".into())); + } + + #[test] + fn primary_key_cells_parses_int8_without_keeping_text() { + let relation = relation_with_oids([("id", true, INT8OID), ("body", false, 25)]); + let mut tuple = text_tuple(&["42"]); + let cells = extract_cells(&relation, &["id".into()], &mut tuple); + assert_eq!(cells, vec![PkCell::Int8(42)]); + assert_eq!(pk_identity(&cells), PkIdentity::Int8(42)); + } + + #[test] + fn primary_key_cells_reads_compact_old_tuple() { + let relation = relation(&[("id", true), ("body", false)]); + let mut tuple = text_tuple(&["42"]); + let cells = extract_cells(&relation, &["id".into()], &mut tuple); + assert_eq!(cells, vec![PkCell::Text("42".into())]); + } + + #[test] + fn primary_key_cells_reads_full_tuple_by_column_index() { + let relation = relation(&[("body", false), ("id", true)]); + let mut tuple = text_tuple(&["hello", "42"]); + let cells = extract_cells(&relation, &["id".into()], &mut tuple); + assert_eq!(cells, vec![PkCell::Text("42".into())]); + } + + #[test] + fn primary_key_cells_preserves_catalog_pk_order() { + let relation = relation(&[("tenant", true), ("id", true), ("body", false)]); + let mut tuple = text_tuple(&["acme", "7", "note"]); + let cells = extract_cells(&relation, &["id".into(), "tenant".into()], &mut tuple); + assert_eq!( + cells, + vec![PkCell::Text("7".into()), PkCell::Text("acme".into())] + ); + assert_eq!( + pk_identity(&cells), + PkIdentity::Composite( + vec![PkCell::Text("7".into()), PkCell::Text("acme".into())].into() + ) + ); } #[test] @@ -189,4 +569,94 @@ mod tests { vec![1, 2] ); } + + #[test] + fn pk_bind_column_parses_int8_once() { + let mut column = PkBindColumn::with_capacity(20, 2); + column.push_text("42".into(), "id").unwrap(); + column.push_text("-7".into(), "id").unwrap(); + assert_eq!(column, PkBindColumn::Int8(vec![42, -7])); + assert!(column.push_text("nope".into(), "id").is_err()); + } + + #[test] + fn pk_bind_column_accepts_parsed_int8_cell() { + let mut column = PkBindColumn::with_capacity(INT8OID, 1); + column.push_cell(PkCell::Int8(42), "id").unwrap(); + assert_eq!(column, PkBindColumn::Int8(vec![42])); + assert!(column.push_cell(PkCell::Text("42".into()), "id").is_err()); + } + + #[test] + fn pk_bind_column_parses_bool_and_keeps_non_builtin_as_text() { + let mut flag = PkBindColumn::with_capacity(16, 1); + flag.push_text("t".into(), "active").unwrap(); + assert_eq!(flag, PkBindColumn::Bool(vec![true])); + + let mut uuid = PkBindColumn::with_capacity(2950, 1); + uuid.push_text("a".into(), "ext_id").unwrap(); + assert_eq!(uuid, PkBindColumn::Text(vec!["a".into()])); + } + + #[test] + fn pk_column_indexes_are_reusable_for_extract() { + let relation = relation(&[("body", false), ("id", true)]); + let keys = pk_column_indexes(&relation, &["id".into()]).unwrap(); + assert_eq!(keys, vec![1]); + let mut tuple = text_tuple(&["hello", "42"]); + let cells = extract_cells(&relation, &["id".into()], &mut tuple); + assert_eq!(cells, vec![PkCell::Text("42".into())]); + } + + #[test] + fn taking_pk_nulls_overlapping_order_column_until_peeked_first() { + let relation = relation_with_oids([("id", true, INT8OID)]); + let mut tuple = text_tuple(&["42"]); + let keys = pk_column_indexes(&relation, &["id".into()]).unwrap(); + let type_oids = pk_type_oids(&relation, &keys).unwrap(); + let _ = + primary_key_cells(&relation, &["id".into()], &keys, &type_oids, &mut tuple).unwrap(); + assert!( + order_column_text(&relation, "id", &tuple).is_err(), + "taking the PK must not be the only extract when id is also the order column" + ); + } + + #[test] + fn overlapping_order_column_is_read_before_pk_take() { + let relation = relation_with_oids([("id", true, INT8OID), ("body", false, 25)]); + let mut tuple = text_tuple(&["42", "hello"]); + let keys = pk_column_indexes(&relation, &["id".into()]).unwrap(); + let type_oids = pk_type_oids(&relation, &keys).unwrap(); + let (cells, order) = take_pk_cells_and_order_text( + &relation, + &["id".into()], + &keys, + &type_oids, + Some("id"), + &mut tuple, + ) + .unwrap(); + assert_eq!(cells, vec![PkCell::Int8(42)]); + assert_eq!(order.as_deref(), Some("42")); + } + + #[test] + fn non_overlapping_order_column_survives_pk_take() { + let relation = relation(&[("id", true), ("created_at", false)]); + let mut tuple = text_tuple(&["7", "2020-01-01"]); + let keys = pk_column_indexes(&relation, &["id".into()]).unwrap(); + let type_oids = pk_type_oids(&relation, &keys).unwrap(); + let (cells, order) = take_pk_cells_and_order_text( + &relation, + &["id".into()], + &keys, + &type_oids, + Some("created_at"), + &mut tuple, + ) + .unwrap(); + assert_eq!(cells, vec![PkCell::Text("7".into())]); + assert_eq!(order.as_deref(), Some("2020-01-01")); + } } diff --git a/crates/koldstore-wal-mirror/src/mirror/async/mod.rs b/crates/koldstore-wal-mirror/src/mirror/async/mod.rs index 0cd762f1..5971fa06 100644 --- a/crates/koldstore-wal-mirror/src/mirror/async/mod.rs +++ b/crates/koldstore-wal-mirror/src/mirror/async/mod.rs @@ -1,14 +1,16 @@ //! Async mirror helpers (PostgreSQL-free). //! -//! Owns the `pgoutput` decoder, tuple→JSON helpers, and apply-batch flush -//! policy. SPI/WAL orchestration stays in `pg_koldstore::mirror`. +//! Owns the `pgoutput` decoder, typed PK identity / bind columns, and +//! apply-batch flush policy. SPI/WAL orchestration stays in `pg_koldstore::mirror`. pub mod apply_row; pub mod batch; pub mod pgoutput; pub use apply_row::{ - parse_pk_bool, parse_pk_ints, pg_value_json, pg_value_text, pk_identity, primary_key_json, + order_column_text, parse_pk_bool, parse_pk_ints, pg_value_text, pk_column_indexes, pk_identity, + pk_type_oids, primary_key_cells, take_pk_cells_and_order_text, PkBindColumn, PkCell, + PkIdentity, BOOLOID, INT2OID, INT4OID, INT8OID, }; pub use batch::{must_flush_before_push, BatchFlushReason, APPLY_BATCH_ROWS}; pub use pgoutput::{ diff --git a/crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs b/crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs index 928ed93a..34a2a5bf 100644 --- a/crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs +++ b/crates/koldstore-wal-mirror/src/mirror/async/pgoutput.rs @@ -41,8 +41,8 @@ pub enum PgOutputValue { Null, /// An unchanged toasted value omitted by PostgreSQL. UnchangedToast, - /// Text-format value. - Text(Vec), + /// Text-format value, already validated as UTF-8. + Text(String), /// Binary-format value. Binary(Vec), } @@ -273,11 +273,14 @@ fn decode_tuple(reader: &mut Reader<'_>) -> Result PgOutputValue::UnchangedToast, b't' | b'b' => { let length = reader.u32("tuple value length")? as usize; - let bytes = reader.bytes(length, "tuple value")?.to_vec(); + let bytes = reader.bytes(length, "tuple value")?; if marker == b't' { - PgOutputValue::Text(bytes) + let text = std::str::from_utf8(bytes) + .map_err(|_| PgOutputDecodeError::InvalidUtf8("tuple value"))? + .to_string(); + PgOutputValue::Text(text) } else { - PgOutputValue::Binary(bytes) + PgOutputValue::Binary(bytes.to_vec()) } } _ => { @@ -432,11 +435,26 @@ mod tests { #[test] fn type_and_logical_message_tags_are_ignored() { - for tag in [b'Y', b'M'] { + for tag in *b"YM" { assert_eq!( decode_message(&[tag, 0x01, 0x02, 0x03]), Ok(PgOutputMessage::Ignored { tag }) ); } } + + #[test] + fn text_tuple_values_decode_as_utf8_strings() { + let mut message = vec![b'I']; + message.extend_from_slice(&42_u32.to_be_bytes()); + message.push(b'N'); + message.extend_from_slice(&1_u16.to_be_bytes()); + message.push(b't'); + message.extend_from_slice(&2_u32.to_be_bytes()); + message.extend_from_slice(b"42"); + let PgOutputMessage::Insert { new, .. } = decode_message(&message).unwrap() else { + panic!("expected insert"); + }; + assert_eq!(new.values[0], PgOutputValue::Text("42".into())); + } } diff --git a/crates/koldstore-wal-mirror/src/mirror/guard.rs b/crates/koldstore-wal-mirror/src/mirror/guard.rs index 6ea7a874..3067a7a9 100644 --- a/crates/koldstore-wal-mirror/src/mirror/guard.rs +++ b/crates/koldstore-wal-mirror/src/mirror/guard.rs @@ -7,6 +7,11 @@ use koldstore_common::{quote_ident, PrimaryKeyColumnShape, QualifiedTableName, SqlStatement}; use thiserror::Error; +use super::shared::relation::{bounded_identifier, legacy_truncated_identifier}; + +const PK_GUARD_FUNCTION_SUFFIX: &str = "_pk_guard"; +const PK_UPDATE_GUARD_TRIGGER_SUFFIX: &str = "_pk_update_guard"; + /// PK-guard planning result. pub type MirrorGuardResult = Result; @@ -58,10 +63,7 @@ pub fn plan_mirror_pk_guard( return Err(MirrorGuardError::MissingPrimaryKey); } - let guard_function_name = QualifiedTableName { - schema: Some("koldstore".to_string()), - name: format!("{}_pk_guard", mirror_table.name), - }; + let guard_function_name = pk_guard_function_relation(&mirror_table.name); let source = source_table.quoted(); let function = SqlStatement::write( "create change-log mirror primary-key guard function", @@ -110,12 +112,13 @@ pub fn plan_mirror_source_teardown( schema: Some("koldstore".to_string()), name: format!("{}_capture", mirror_table.name), }; - let guard_function_name = QualifiedTableName { + let guard_function_name = pk_guard_function_relation(&mirror_table.name); + let legacy_guard_function_name = QualifiedTableName { schema: Some("koldstore".to_string()), - name: format!("{}_pk_guard", mirror_table.name), + name: legacy_truncated_identifier(&mirror_table.name, PK_GUARD_FUNCTION_SUFFIX), }; let source = source_table.quoted(); - let mut statements = Vec::with_capacity(6); + let mut statements = Vec::with_capacity(8); for operation in MirrorOperation::ALL { let trigger_name = operation.capture_trigger_name(&mirror_table.name); statements.push(SqlStatement::write( @@ -126,10 +129,19 @@ pub fn plan_mirror_source_teardown( &drop_trigger_if_present_sql(&trigger_name, &source), )?); } + let bounded_trigger = pk_guard_trigger_name(&mirror_table.name); + let legacy_trigger = + legacy_truncated_identifier(&mirror_table.name, PK_UPDATE_GUARD_TRIGGER_SUFFIX); statements.push(SqlStatement::write( "drop change-log mirror primary-key guard trigger", - &drop_trigger_if_present_sql(&pk_guard_trigger_name(&mirror_table.name), &source), + &drop_trigger_if_present_sql(&bounded_trigger, &source), )?); + if legacy_trigger != bounded_trigger { + statements.push(SqlStatement::write( + "drop legacy change-log mirror primary-key guard trigger", + &drop_trigger_if_present_sql(&legacy_trigger, &source), + )?); + } statements.push(SqlStatement::write( "drop change-log mirror capture function", &format!("DROP FUNCTION IF EXISTS {}()", function_name.quoted()), @@ -138,11 +150,29 @@ pub fn plan_mirror_source_teardown( "drop change-log mirror primary-key guard function", &format!("DROP FUNCTION IF EXISTS {}()", guard_function_name.quoted()), )?); + if legacy_guard_function_name.name != guard_function_name.name { + statements.push(SqlStatement::write( + "drop legacy change-log mirror primary-key guard function", + &format!( + "DROP FUNCTION IF EXISTS {}()", + legacy_guard_function_name.quoted() + ), + )?); + } Ok(statements) } -fn pk_guard_trigger_name(mirror_table_name: &str) -> String { - format!("{mirror_table_name}_pk_update_guard") +/// Builds the PostgreSQL-safe PK-update guard trigger name for a mirror table. +#[must_use] +pub fn pk_guard_trigger_name(mirror_table_name: &str) -> String { + bounded_identifier(mirror_table_name, PK_UPDATE_GUARD_TRIGGER_SUFFIX) +} + +fn pk_guard_function_relation(mirror_table_name: &str) -> QualifiedTableName { + QualifiedTableName { + schema: Some("koldstore".to_string()), + name: bounded_identifier(mirror_table_name, PK_GUARD_FUNCTION_SUFFIX), + } } /// Drops a trigger only when it already exists, without PostgreSQL's @@ -176,7 +206,10 @@ fn pk_guard_function_sql( .collect::>(); if let Some(order_column) = order_column { let name = quote_ident(order_column); - distinct.push(format!("OLD.{name} IS DISTINCT FROM NEW.{name}")); + let predicate = format!("OLD.{name} IS DISTINCT FROM NEW.{name}"); + if !distinct.contains(&predicate) { + distinct.push(predicate); + } } let distinct = distinct.join("\n OR "); @@ -215,7 +248,10 @@ fn plan_pk_guard_trigger( .map(|column| quote_ident(column.column().as_str())) .collect::>(); if let Some(order_column) = order_column { - of_columns.push(quote_ident(order_column)); + let name = quote_ident(order_column); + if !of_columns.contains(&name) { + of_columns.push(name); + } } let of_list = of_columns.join(", "); let drop_sql = drop_trigger_if_present_sql(&trigger_name, source_table); @@ -234,3 +270,62 @@ FOR EACH ROW EXECUTE FUNCTION {function_name}() ) .map_err(|error| MirrorGuardError::Sql(error.to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + use koldstore_common::{ + ColumnId, PgTypeName, PgTypeOid, PgTypmod, PkColumn, PkOrdinal, PrimaryKeyColumnShape, + }; + + fn pk_column(name: &str) -> PrimaryKeyColumnShape { + PrimaryKeyColumnShape::new( + ColumnId::from_attnum(1), + PkColumn::new(name).unwrap(), + PkOrdinal::new(1).unwrap(), + PgTypeOid::new(20).unwrap(), + PgTypeName::new("bigint").unwrap(), + PgTypmod::new(-1), + None, + None, + true, + ) + } + + #[test] + fn short_mirror_names_keep_readable_guard_suffixes() { + assert_eq!( + pk_guard_trigger_name("public_messages__cl"), + "public_messages__cl_pk_update_guard" + ); + assert_eq!( + pk_guard_function_relation("public_messages__cl").name, + "public_messages__cl_pk_guard" + ); + } + + #[test] + fn long_mirror_names_keep_guard_identifiers_within_postgres_limit() { + let mirror = format!("{}__cl", "a".repeat(59)); + assert_eq!(mirror.len(), 63); + + let trigger = pk_guard_trigger_name(&mirror); + let function = pk_guard_function_relation(&mirror).name; + assert!(trigger.len() <= 63, "trigger={trigger}"); + assert!(function.len() <= 63, "function={function}"); + assert!(trigger.ends_with(PK_UPDATE_GUARD_TRIGGER_SUFFIX)); + assert!(function.ends_with(PK_GUARD_FUNCTION_SUFFIX)); + assert_ne!(trigger, mirror); + + let source = QualifiedTableName::parse("public.messages").unwrap(); + let mirror_table = QualifiedTableName::parse(&format!("koldstore.{mirror}")).unwrap(); + let plan = plan_mirror_pk_guard(&source, &mirror_table, &[pk_column("id")], None).unwrap(); + assert!(plan + .trigger + .sql + .contains(&format!("CREATE TRIGGER \"{trigger}\""))); + assert!(plan.function.sql.contains(&format!( + "CREATE OR REPLACE FUNCTION \"koldstore\".\"{function}\"()" + ))); + } +} diff --git a/crates/koldstore-wal-mirror/src/mirror/mod.rs b/crates/koldstore-wal-mirror/src/mirror/mod.rs index 064d6fbe..591a4fb6 100644 --- a/crates/koldstore-wal-mirror/src/mirror/mod.rs +++ b/crates/koldstore-wal-mirror/src/mirror/mod.rs @@ -13,20 +13,22 @@ pub mod guard; pub mod shared; pub use guard::{ - plan_mirror_pk_guard, plan_mirror_source_teardown, MirrorGuardError, MirrorGuardResult, - MirrorPkGuardPlan, + pk_guard_trigger_name, plan_mirror_pk_guard, plan_mirror_source_teardown, MirrorGuardError, + MirrorGuardResult, MirrorPkGuardPlan, }; pub use r#async::{ - decode_message, must_flush_before_push, parse_pk_bool, parse_pk_ints, pg_value_json, - pg_value_text, pk_identity, primary_key_json, BatchFlushReason, PgOutputColumn, - PgOutputDecodeError, PgOutputMessage, PgOutputRelation, PgOutputTuple, PgOutputValue, - APPLY_BATCH_ROWS, + decode_message, must_flush_before_push, order_column_text, parse_pk_bool, parse_pk_ints, + pg_value_text, pk_column_indexes, pk_identity, pk_type_oids, primary_key_cells, + take_pk_cells_and_order_text, BatchFlushReason, PgOutputColumn, PgOutputDecodeError, + PgOutputMessage, PgOutputRelation, PgOutputTuple, PgOutputValue, PkBindColumn, PkCell, + PkIdentity, APPLY_BATCH_ROWS, BOOLOID, INT2OID, INT4OID, INT8OID, }; pub use shared::{ - mirror_relation_for_source, plan_async_mirror_batch_delete_existing, - plan_async_mirror_batch_update, plan_async_mirror_batch_upsert, plan_drop_mirror_table, - plan_mirror_force_flush_stats, plan_mirror_oldest_rows_max_seq, plan_mirror_op_stats, - plan_mirror_pk_column_renames, plan_mirror_schema, plan_mirror_schema_with_order_key, + mirror_relation_for_source, mirror_seq_index_name, mirror_tombstone_index_name, + plan_async_mirror_batch_delete_existing, plan_async_mirror_batch_update, + plan_async_mirror_batch_upsert, plan_drop_mirror_table, plan_mirror_force_flush_stats, + plan_mirror_oldest_rows_max_seq, plan_mirror_op_stats, plan_mirror_pk_column_renames, + plan_mirror_relation_rename, plan_mirror_schema, plan_mirror_schema_with_order_key, plan_mirror_stats, plan_select_mirror_last_rows, plan_select_mirror_last_rows_with_params, plan_select_mirror_rows_after_seq, plan_select_mirror_rows_after_seq_with_params, plan_upsert_mirror_row, published_column_list, quoted_pk_columns, MirrorColumn, MirrorError, diff --git a/crates/koldstore-wal-mirror/src/mirror/shared/mod.rs b/crates/koldstore-wal-mirror/src/mirror/shared/mod.rs index c6a4f556..c690aaf7 100644 --- a/crates/koldstore-wal-mirror/src/mirror/shared/mod.rs +++ b/crates/koldstore-wal-mirror/src/mirror/shared/mod.rs @@ -26,7 +26,8 @@ pub use relation::{ }; pub use row_json::MirrorSeqStats; pub use schema::{ - plan_drop_mirror_table, plan_mirror_pk_column_renames, plan_mirror_schema, + mirror_seq_index_name, mirror_tombstone_index_name, plan_drop_mirror_table, + plan_mirror_pk_column_renames, plan_mirror_relation_rename, plan_mirror_schema, plan_mirror_schema_with_order_key, MirrorSchemaPlan, }; pub use statement::{SqlAccess, SqlParamType, SqlStatement}; diff --git a/crates/koldstore-wal-mirror/src/mirror/shared/relation.rs b/crates/koldstore-wal-mirror/src/mirror/shared/relation.rs index 3c258699..18661f24 100644 --- a/crates/koldstore-wal-mirror/src/mirror/shared/relation.rs +++ b/crates/koldstore-wal-mirror/src/mirror/shared/relation.rs @@ -7,8 +7,10 @@ use koldstore_common::{is_safe_identifier, TableName}; use super::error::{MirrorError, MirrorResult}; pub use koldstore_common::KOLDSTORE_SCHEMA; -/// Suffix appended to the source table name for its latest-state mirror. +/// Suffix appended to the schema-qualified source identity for its mirror. pub const CHANGE_LOG_MIRROR_SUFFIX: &str = "__cl"; +const MAX_POSTGRES_IDENTIFIER_BYTES: usize = 63; +const MIRROR_NAME_HASH_HEX_LEN: usize = 16; /// Validated mirror table relation. #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,7 +51,7 @@ impl MirrorRelation { /// Returns an error when the generated relation would not be a safe PostgreSQL /// identifier for pg-koldstore-owned DDL. pub fn mirror_relation_for_source(source_table: &TableName) -> MirrorResult { - let mirror_name = format!("{}{}", source_table.relation(), CHANGE_LOG_MIRROR_SUFFIX); + let mirror_name = mirror_relation_name(source_table); if !is_safe_identifier(&mirror_name) { return Err(MirrorError::InvalidMirrorName(mirror_name)); } @@ -57,3 +59,69 @@ pub fn mirror_relation_for_source(source_table: &TableName) -> MirrorResult String { + let source_name = source_table.schema().map_or_else( + || source_table.relation().to_string(), + |schema| format!("{schema}_{}", source_table.relation()), + ); + bounded_identifier(&source_name, CHANGE_LOG_MIRROR_SUFFIX) +} + +/// Builds a deterministic PostgreSQL identifier from a prefix and suffix. +/// +/// Long names retain the suffix and replace the omitted middle with a stable +/// hash so independently generated artifacts cannot collide by truncation. +pub(crate) fn bounded_identifier(prefix: &str, suffix: &str) -> String { + let candidate = format!("{prefix}{suffix}"); + if candidate.len() <= MAX_POSTGRES_IDENTIFIER_BYTES { + return candidate; + } + let prefix_len = MAX_POSTGRES_IDENTIFIER_BYTES - 1 - MIRROR_NAME_HASH_HEX_LEN - suffix.len(); + let hash = stable_name_hash(prefix); + format!("{}_{hash:016x}{suffix}", &prefix[..prefix_len]) +} + +/// Returns PostgreSQL's historical first-63-byte truncation for legacy names. +pub(crate) fn legacy_truncated_identifier(prefix: &str, suffix: &str) -> String { + format!("{prefix}{suffix}") + .chars() + .take(MAX_POSTGRES_IDENTIFIER_BYTES) + .collect() +} + +fn stable_name_hash(value: &str) -> u64 { + value + .as_bytes() + .iter() + .fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn includes_the_source_schema_in_the_mirror_name() { + let source = TableName::parse("db1.messages").expect("source table name"); + + let mirror = mirror_relation_for_source(&source).expect("mirror relation"); + + assert_eq!(mirror.table_name().as_str(), "koldstore.db1_messages__cl"); + } + + #[test] + fn bounds_long_source_names_without_losing_determinism() { + let source = TableName::parse(format!("{}.{}", "a".repeat(63), "b".repeat(63))) + .expect("source table name"); + + let first = mirror_relation_for_source(&source).expect("first mirror relation"); + let second = mirror_relation_for_source(&source).expect("second mirror relation"); + + assert_eq!(first, second); + assert!(first.relation().len() <= 63); + assert!(first.relation().ends_with(CHANGE_LOG_MIRROR_SUFFIX)); + } +} diff --git a/crates/koldstore-wal-mirror/src/mirror/shared/schema.rs b/crates/koldstore-wal-mirror/src/mirror/shared/schema.rs index 076147bd..912459ba 100644 --- a/crates/koldstore-wal-mirror/src/mirror/shared/schema.rs +++ b/crates/koldstore-wal-mirror/src/mirror/shared/schema.rs @@ -7,7 +7,22 @@ use koldstore_common::{ use super::columns::MirrorColumn; use super::error::{MirrorError, MirrorResult}; -use super::relation::MirrorRelation; +use super::relation::{bounded_identifier, legacy_truncated_identifier, MirrorRelation}; + +const SEQ_INDEX_SUFFIX: &str = "_seq_idx"; +const TOMBSTONE_INDEX_SUFFIX: &str = "_tombstone_seq_idx"; + +/// Builds the PostgreSQL-safe seq index name for a mirror relation. +#[must_use] +pub fn mirror_seq_index_name(mirror_relation: &str) -> String { + bounded_identifier(mirror_relation, SEQ_INDEX_SUFFIX) +} + +/// Builds the PostgreSQL-safe tombstone seq index name for a mirror relation. +#[must_use] +pub fn mirror_tombstone_index_name(mirror_relation: &str) -> String { + bounded_identifier(mirror_relation, TOMBSTONE_INDEX_SUFFIX) +} /// Primitive mirror table schema statements. #[derive(Debug, Clone, PartialEq, Eq)] @@ -72,6 +87,45 @@ pub fn plan_mirror_pk_column_renames( Ok(statements) } +/// Plans the DDL required to rename a mirror and its generated indexes. +/// +/// The legacy index lookup keeps mirrors created before bounded index naming +/// movable. PostgreSQL silently truncated those names at creation time. +/// +/// # Errors +/// +/// Returns an error when any generated statement metadata is invalid. +pub fn plan_mirror_relation_rename( + old_mirror: &MirrorRelation, + new_mirror: &MirrorRelation, +) -> MirrorResult> { + if old_mirror == new_mirror { + return Ok(Vec::new()); + } + + let old_relation = old_mirror.relation(); + let new_relation = new_mirror.relation(); + let mut statements = Vec::with_capacity(5); + statements.push(SqlStatement::write( + "rename change-log mirror table", + &format!( + "ALTER TABLE {} RENAME TO {}", + old_mirror.quoted(), + quote_ident(new_relation) + ), + )?); + for suffix in [SEQ_INDEX_SUFFIX, TOMBSTONE_INDEX_SUFFIX] { + let old_legacy_name = legacy_truncated_identifier(old_relation, suffix); + let old_bounded_name = bounded_identifier(old_relation, suffix); + let new_name = bounded_identifier(new_relation, suffix); + statements.push(rename_index_if_present(&old_legacy_name, &new_name)?); + if old_bounded_name != old_legacy_name { + statements.push(rename_index_if_present(&old_bounded_name, &new_name)?); + } + } + Ok(statements) +} + /// Plans primitive mirror table storage statements. /// /// # Errors @@ -129,9 +183,8 @@ pub fn plan_mirror_schema_with_order_key( "CREATE TABLE IF NOT EXISTS {quoted_mirror} (\n {}\n)", ddl_columns.join(",\n ") ); - let seq_index_name = quote_ident(&format!("{}_seq_idx", mirror_table.relation())); - let tombstone_index_name = - quote_ident(&format!("{}_tombstone_seq_idx", mirror_table.relation())); + let seq_index_name = quote_ident(&mirror_seq_index_name(mirror_table.relation())); + let tombstone_index_name = quote_ident(&mirror_tombstone_index_name(mirror_table.relation())); Ok(MirrorSchemaPlan { collision_probe: SqlStatement::read( @@ -156,6 +209,18 @@ pub fn plan_mirror_schema_with_order_key( }) } +fn rename_index_if_present(old_name: &str, new_name: &str) -> MirrorResult { + Ok(SqlStatement::write( + "rename change-log mirror index", + &format!( + "ALTER INDEX IF EXISTS {}.{} RENAME TO {}", + quote_ident(super::relation::KOLDSTORE_SCHEMA), + quote_ident(old_name), + quote_ident(new_name) + ), + )?) +} + /// Plans idempotent mirror table drop. /// /// # Errors diff --git a/crates/koldstore-wal-mirror/src/wal/apply_contract.rs b/crates/koldstore-wal-mirror/src/wal/apply_contract.rs index d0a808c1..ee574e87 100644 --- a/crates/koldstore-wal-mirror/src/wal/apply_contract.rs +++ b/crates/koldstore-wal-mirror/src/wal/apply_contract.rs @@ -33,8 +33,11 @@ pub struct BoundedApplyRequest { pub upper_bound: Option, /// Skip whole pgoutput transactions with `end_lsn <= skip_through`. pub skip_through: Option, - /// When true, advance the slot to the previously committed durable checkpoint - /// and record a new pending `applied_lsn`. Flush prune fences must use false. + /// When true, record a new pending `applied_lsn` (and empty-advance through + /// non-publication WAL when [`Self::advance_slot_on_empty`] is set). Flush + /// prune fences must use false so an uncommitted flush txn cannot claim new + /// apply progress. Previously committed `applied_lsn` is still acknowledged + /// at the start of every apply pass so recyclable WAL is freed. pub acknowledge_durable_checkpoint: bool, /// When true, an empty publication peek advances `confirmed_flush` through /// non-publication WAL. Wake-driven async-commit retries must use false so @@ -66,7 +69,7 @@ impl BoundedApplyRequest { } } - /// Strong-consistency fence: apply through a fixed durable WAL upper bound. + /// Committed-change fence: apply through a fixed durable WAL upper bound. #[must_use] pub fn upto_fence(fence: WalFenceLsn) -> Self { Self { diff --git a/crates/koldstore-wal-mirror/tests/storage_contract.rs b/crates/koldstore-wal-mirror/tests/storage_contract.rs index 7b25688b..62b557cd 100644 --- a/crates/koldstore-wal-mirror/tests/storage_contract.rs +++ b/crates/koldstore-wal-mirror/tests/storage_contract.rs @@ -24,12 +24,12 @@ fn pk_shape(name: &str, type_name: &str) -> PrimaryKeyColumnShape { } #[test] -fn mirror_relation_uses_clean_schema_storage_name() { +fn mirror_relation_includes_the_source_schema() { let source = TableName::parse("app.items").unwrap(); let mirror = mirror_relation_for_source(&source).unwrap(); - assert_eq!(mirror.table_name().as_str(), "koldstore.items__cl"); - assert_eq!(mirror.quoted(), "\"koldstore\".\"items__cl\""); + assert_eq!(mirror.table_name().as_str(), "koldstore.app_items__cl"); + assert_eq!(mirror.quoted(), "\"koldstore\".\"app_items__cl\""); } #[test] @@ -49,7 +49,7 @@ fn mirror_schema_plan_creates_exact_pk_storage_and_indexes() { assert!(plan .create_table .sql - .contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"items__cl\"")); + .contains("CREATE TABLE IF NOT EXISTS \"koldstore\".\"app_items__cl\"")); assert!(plan.create_table.sql.contains("\"id\" bigint NOT NULL")); assert!(plan.create_table.sql.contains("\"seq\" bigint NOT NULL")); assert!(!plan.create_table.sql.contains("commit_lsn")); @@ -57,11 +57,11 @@ fn mirror_schema_plan_creates_exact_pk_storage_and_indexes() { assert!(plan .seq_index .sql - .contains("ON \"koldstore\".\"items__cl\" (\"seq\")")); + .contains("ON \"koldstore\".\"app_items__cl\" (\"seq\")")); assert!(plan .tombstone_index .sql - .contains("ON \"koldstore\".\"items__cl\" (\"seq\") WHERE \"op\" = 3")); + .contains("ON \"koldstore\".\"app_items__cl\" (\"seq\") WHERE \"op\" = 3")); assert_eq!(plan.create_statements().len(), 3); } @@ -77,7 +77,7 @@ fn mirror_upsert_builder_returns_latest_state_write_fragment() { ) .unwrap(); - assert!(sql.contains("INSERT INTO \"koldstore\".\"items__cl\"")); + assert!(sql.contains("INSERT INTO \"koldstore\".\"app_items__cl\"")); assert!(sql.contains("VALUES (NEW.\"id\", SNOWFLAKE_ID(), 2)")); assert!(sql.contains("ON CONFLICT (\"id\") DO UPDATE")); assert!(!sql.contains("commit_lsn")); @@ -196,6 +196,6 @@ fn mirror_changes_since_scan_keeps_callers_in_control_of_predicates() { ] ); assert!(stats.sql.contains("'row_count', count(*)")); - assert!(stats.sql.contains("FROM \"koldstore\".\"items__cl\"")); + assert!(stats.sql.contains("FROM \"koldstore\".\"app_items__cl\"")); assert!(stats.param_types.is_empty()); } diff --git a/crates/pg_koldstore-shell-tests/tests/async_pgoutput.rs b/crates/pg_koldstore-shell-tests/tests/async_pgoutput.rs index feb43233..7f4444a4 100644 --- a/crates/pg_koldstore-shell-tests/tests/async_pgoutput.rs +++ b/crates/pg_koldstore-shell-tests/tests/async_pgoutput.rs @@ -66,7 +66,7 @@ fn decodes_insert_update_delete_and_commit_messages() { panic!("expected insert"); }; assert_eq!(relation_id, 42); - assert_eq!(new.values[0], PgOutputValue::Text(b"7".to_vec())); + assert_eq!(new.values[0], PgOutputValue::Text("7".into())); let mut update = vec![b'U']; update.extend_from_slice(&42_u32.to_be_bytes()); @@ -77,8 +77,8 @@ fn decodes_insert_update_delete_and_commit_messages() { let PgOutputMessage::Update { old, new, .. } = decode_message(&update).unwrap() else { panic!("expected update"); }; - assert_eq!(old.unwrap().values[0], PgOutputValue::Text(b"7".to_vec())); - assert_eq!(new.values[1], PgOutputValue::Text(b"updated".to_vec())); + assert_eq!(old.unwrap().values[0], PgOutputValue::Text("7".into())); + assert_eq!(new.values[1], PgOutputValue::Text("updated".into())); let mut delete = vec![b'D']; delete.extend_from_slice(&42_u32.to_be_bytes()); @@ -113,7 +113,7 @@ fn rejects_truncated_and_unknown_messages() { #[test] fn handles_truncate_origin_type_and_message_tags() { - for tag in [b'T', b'Y', b'M'] { + for tag in *b"TYM" { let message = decode_message(&[tag]).unwrap(); assert_eq!(message, PgOutputMessage::Ignored { tag }); } diff --git a/crates/pg_koldstore/src/catalog/resolve.rs b/crates/pg_koldstore/src/catalog/resolve.rs index 865612fd..b9074597 100644 --- a/crates/pg_koldstore/src/catalog/resolve.rs +++ b/crates/pg_koldstore/src/catalog/resolve.rs @@ -51,6 +51,35 @@ pub fn mirror_relation_by_table_oid( .transpose() } +/// Returns whether another active managed table references `mirror_relation`. +/// +/// # Errors +/// +/// Returns an error when PostgreSQL cannot inspect the managed-schema catalog. +pub fn mirror_has_other_active_owner( + table_oid: pgrx::pg_sys::Oid, + mirror_relation: &QualifiedTableName, +) -> Result { + use pgrx::datum::DatumWithOid; + + let mirror_relation = mirror_relation.quoted(); + pgrx::Spi::get_one_with_args::( + "SELECT EXISTS (\ + SELECT 1 \ + FROM koldstore.schemas \ + WHERE active \ + AND table_oid <> $1::oid \ + AND mirror_relation = pg_catalog.to_regclass($2)\ + )", + &[ + DatumWithOid::from(table_oid), + DatumWithOid::from(mirror_relation.as_str()), + ], + ) + .map_err(|error| error.to_string())? + .ok_or_else(|| "mirror ownership query returned no result".to_string()) +} + /// Resolves a registered storage ID by name. /// /// # Errors diff --git a/crates/pg_koldstore/src/hooks/ddl.rs b/crates/pg_koldstore/src/hooks/ddl.rs index 2bda314e..70404785 100644 --- a/crates/pg_koldstore/src/hooks/ddl.rs +++ b/crates/pg_koldstore/src/hooks/ddl.rs @@ -1,11 +1,12 @@ //! DDL and ProcessUtility integration for KoldStore table options. //! -//! DROP TABLE cleanup planning lives in `koldstore-migrate`; this module +//! DROP cleanup planning lives in `koldstore-migrate`; this module //! re-exports those plans for the extension shell and installs the live //! ProcessUtility hook used for `ALTER TABLE … SET/RESET` KoldStore options -//! and managed `DROP TABLE` teardown. Managed-table schema changes (including -//! `RENAME COLUMN`) also refresh catalog metadata and rename-sensitive runtime -//! artifacts so DML keeps working without waiting for the next flush. +//! and managed `DROP TABLE` / `DROP SCHEMA … CASCADE` teardown. Managed-table +//! schema changes (including `RENAME COLUMN`) also refresh catalog metadata and +//! rename-sensitive runtime artifacts so DML keeps working without waiting for +//! the next flush. pub use koldstore_migrate::drop_table::{ plan_drop_table_cleanup, DropTableCleanupError, DropTableCleanupOutcome, DropTableCleanupPlan, @@ -14,6 +15,7 @@ pub use koldstore_migrate::drop_table::{ #[cfg(feature = "pg")] mod process_utility { + use std::cell::Cell; use std::collections::HashMap; use std::ffi::CStr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +29,34 @@ mod process_utility { static REGISTERED: AtomicBool = AtomicBool::new(false); static mut PREVIOUS: pg_sys::ProcessUtility_hook_type = None; + // Nesting counter so demigrate/unmanage can TRUNCATE the heap while rebuilding + // it, without opening a hole for user-issued TRUNCATE on managed tables. + thread_local! { + static ALLOW_MANAGED_TRUNCATE: Cell = const { Cell::new(0) }; + } + + /// RAII guard that permits managed-table TRUNCATE for demigrate rehydrate. + pub(crate) struct AllowManagedTruncateGuard; + + impl AllowManagedTruncateGuard { + /// Enters a demigrate/unmanage scope that may issue internal TRUNCATE. + #[must_use] + pub(crate) fn enter() -> Self { + ALLOW_MANAGED_TRUNCATE.with(|depth| depth.set(depth.get().saturating_add(1))); + Self + } + } + + impl Drop for AllowManagedTruncateGuard { + fn drop(&mut self) { + ALLOW_MANAGED_TRUNCATE.with(|depth| depth.set(depth.get().saturating_sub(1))); + } + } + + fn managed_truncate_allowed() -> bool { + ALLOW_MANAGED_TRUNCATE.with(|depth| depth.get() > 0) + } + pub(super) fn register() { if REGISTERED.swap(true, Ordering::AcqRel) { return; @@ -55,7 +85,9 @@ mod process_utility { let mut captured = None; let mut has_standard_actions = true; let mut drop_oids = Vec::new(); + let mut truncate_oids = Vec::new(); let mut refresh_oid = None; + let mut renamed_schema = None; let mut copy_from_oid = None; if !copied.is_null() && !(*copied).utilityStmt.is_null() { match (*(*copied).utilityStmt).type_ { @@ -69,6 +101,14 @@ mod process_utility { // `ALTER TABLE … RENAME COLUMN` is RenameStmt, not AlterTableStmt. let stmt = (*copied).utilityStmt.cast::(); refresh_oid = rename_stmt_relation_oid(stmt); + renamed_schema = rename_stmt_schema_name(stmt); + } + pg_sys::NodeTag::T_AlterObjectSchemaStmt => { + // `ALTER TABLE … SET SCHEMA` is neither AlterTableStmt nor RenameStmt. + let stmt = (*copied) + .utilityStmt + .cast::(); + refresh_oid = alter_object_schema_relation_oid(stmt); } pg_sys::NodeTag::T_DropStmt => { let stmt = (*copied).utilityStmt.cast::(); @@ -80,14 +120,26 @@ mod process_utility { copy_from_oid = relation_oid_from_range_var((*stmt).relation); } } + pg_sys::NodeTag::T_TruncateStmt => { + let stmt = (*copied).utilityStmt.cast::(); + truncate_oids = truncate_table_oids(stmt); + } _ => {} } } + if !managed_truncate_allowed() + && truncate_oids + .into_iter() + .any(crate::catalog::cache::is_managed_relation) + { + pgrx::error!( + "TRUNCATE is not supported for KoldStore-managed tables; use DELETE so WAL capture preserves hot/cold consistency" + ); + } let mut mirrors = Vec::new(); if !drop_oids.is_empty() { - mirrors = cleanup_managed_tables_before_drop(&drop_oids).unwrap_or_else(|error| { - pgrx::error!("KoldStore DROP TABLE cleanup failed: {error}") - }); + mirrors = cleanup_managed_tables_before_drop(&drop_oids) + .unwrap_or_else(|error| pgrx::error!("KoldStore DROP cleanup failed: {error}")); } if has_standard_actions { delegate(copied, query, read_only, context, params, env, dest, qc); @@ -128,6 +180,17 @@ mod process_utility { } } } + if let Some(schema_name) = renamed_schema { + if crate::catalog::cache::managed_catalog_ready() { + pg_sys::CommandCounterIncrement(); + crate::sql::migrate::sync_active_mirror_relation_names_in_schema(&schema_name) + .unwrap_or_else(|error| { + pgrx::error!( + "KoldStore schema rename could not rehome managed mirrors: {error}" + ) + }); + } + } } } @@ -260,6 +323,26 @@ mod process_utility { } } + /// Resolves every explicitly targeted relation before TRUNCATE executes. + unsafe fn truncate_table_oids(stmt: *mut pg_sys::TruncateStmt) -> Vec { + unsafe { + if stmt.is_null() || (*stmt).relations.is_null() { + return Vec::new(); + } + let relations = (*stmt).relations; + let mut oids = Vec::with_capacity((*relations).length as usize); + for index in 0..(*relations).length as usize { + let relation = (*(*relations).elements.add(index)) + .ptr_value + .cast::(); + if let Some(oid) = relation_oid_from_range_var(relation) { + oids.push(oid); + } + } + oids + } + } + /// Resolves the table OID for column/table renames that need schema sync. unsafe fn rename_stmt_relation_oid(stmt: *mut pg_sys::RenameStmt) -> Option { unsafe { @@ -275,6 +358,29 @@ mod process_utility { } } + /// Resolves the table OID for `ALTER TABLE … SET SCHEMA`. + unsafe fn alter_object_schema_relation_oid( + stmt: *mut pg_sys::AlterObjectSchemaStmt, + ) -> Option { + unsafe { + if stmt.is_null() || (*stmt).objectType != pg_sys::ObjectType::OBJECT_TABLE { + return None; + } + relation_oid_from_range_var((*stmt).relation) + } + } + + /// Returns the new schema name for a schema rename statement. + unsafe fn rename_stmt_schema_name(stmt: *mut pg_sys::RenameStmt) -> Option { + unsafe { + if stmt.is_null() || (*stmt).renameType != pg_sys::ObjectType::OBJECT_SCHEMA { + return None; + } + let new_name = (*stmt).newname; + (!new_name.is_null()).then(|| CStr::from_ptr(new_name).to_string_lossy().into_owned()) + } + } + /// True when `role` owns relation `oid` (PG15 vs PG16+ ACL helper names differ). unsafe fn relation_ownercheck(oid: pg_sys::Oid, role: pg_sys::Oid) -> bool { #[cfg(feature = "pg15")] @@ -293,6 +399,10 @@ pub(crate) fn register_process_utility_hook() { process_utility::register(); } +/// Allows demigrate/unmanage SPI to TRUNCATE a still-managed heap safely. +#[cfg(feature = "pg")] +pub(crate) use process_utility::AllowManagedTruncateGuard; + #[cfg(feature = "pg")] fn option_value<'a>( values: &'a std::collections::HashMap, @@ -364,6 +474,9 @@ fn ensure_initial_management( None, None, None, + None, + None, + None, ); Ok(()) } @@ -465,6 +578,41 @@ fn apply_flush_policy_updates( Ok(()) } +#[cfg(feature = "pg")] +fn apply_parquet_layout_updates( + options: &mut koldstore_common::ManageTableOptions, + values: &std::collections::HashMap, +) -> Result<(), String> { + if let Some(value) = option_value(values, "koldstore_parquet_row_group_size") { + let row_count = value + .parse::() + .map_err(|_| "parquet_row_group_size must be a positive integer")?; + if row_count == 0 { + return Err("parquet_row_group_size must be greater than zero".into()); + } + *options = options.clone().with_parquet_row_group_size(row_count); + } + if let Some(value) = option_value(values, "koldstore_parquet_data_page_row_count_limit") { + let row_count = value + .parse::() + .map_err(|_| "parquet_data_page_row_count_limit must be a positive integer")?; + if row_count == 0 { + return Err("parquet_data_page_row_count_limit must be greater than zero".into()); + } + *options = options + .clone() + .with_parquet_data_page_row_count_limit(row_count); + } + if let Some(value) = option_value(values, "koldstore_parquet_bloom_filter_fpp") { + let fpp = value + .parse::() + .map_err(|_| "parquet_bloom_filter_fpp must be greater than 0 and less than 1")?; + let fpp = koldstore_common::ParquetBloomFilterFpp::new(fpp)?; + *options = options.clone().with_parquet_bloom_filter_fpp(fpp); + } + Ok(()) +} + #[cfg(feature = "pg")] fn apply_management_options( table_oid: pgrx::pg_sys::Oid, @@ -507,6 +655,7 @@ fn apply_management_options( let mut options = koldstore_common::ManageTableOptions::from_value(¤t.0["options"]); let (min, file, max) = resolve_flush_batching(values, options.flush_policy().as_ref())?; apply_flush_policy_updates(&mut options, values, min, file, max)?; + apply_parquet_layout_updates(&mut options, values)?; let json = pgrx::JsonB(options.to_value()); let update = koldstore_migrate::register::plan_update_schema_options() .map_err(|error| error.to_string())?; diff --git a/crates/pg_koldstore/src/hooks/drop_cleanup.rs b/crates/pg_koldstore/src/hooks/drop_cleanup.rs index 80681e30..76130a53 100644 --- a/crates/pg_koldstore/src/hooks/drop_cleanup.rs +++ b/crates/pg_koldstore/src/hooks/drop_cleanup.rs @@ -1,4 +1,8 @@ -//! DROP TABLE ProcessUtility cleanup for managed KoldStore tables. +//! DROP TABLE / DROP SCHEMA ProcessUtility cleanup for managed KoldStore tables. +//! +//! `DROP SCHEMA … CASCADE` does not emit per-table `DropStmt`s through +//! ProcessUtility, so schema drops must resolve managed heaps in the target +//! namespace here before PostgreSQL removes them. //! //! Order matters to avoid deadlocks with an in-flight flush: //! 1. Resolve OIDs with `NoLock` (do not hold relation locks across waits) @@ -7,13 +11,15 @@ //! 4. Catalog + object-store cleanup, then allow PostgreSQL DROP //! 5. Drop the change-log mirror after the heap is gone +use std::ffi::{CStr, CString}; + use koldstore_common::QualifiedTableName; use koldstore_migrate::drop_table::{plan_drop_table_cleanup, DropTableCleanupPolicy}; use koldstore_storage::{render_regular_table_prefix, PathTemplate, StorageClient}; use pgrx::datum::DatumWithOid; use pgrx::pg_sys; -/// Cancels jobs and removes cold artifacts for managed tables in a DROP TABLE. +/// Cancels jobs and removes cold artifacts for managed tables about to drop. /// /// # Errors /// @@ -84,6 +90,16 @@ fn cleanup_one_managed_table_before_drop( let relation = crate::catalog::resolve::relation_context(table_oid)?; let storage = crate::catalog::resolve::active_flush_storage_context(table_oid)?; let mirror = crate::catalog::resolve::mirror_relation_by_table_oid(table_oid)?; + if let Some(mirror_relation) = &mirror { + if crate::catalog::resolve::mirror_has_other_active_owner(table_oid, mirror_relation)? { + return Err(format!( + "refusing to drop managed table {}.{}: mirror {} is still referenced by another active managed table", + relation.namespace, + relation.name, + mirror_relation.quoted() + )); + } + } let prefix = render_regular_table_prefix( &PathTemplate::new(&storage.regular_path_tmpl), &relation.namespace, @@ -134,7 +150,7 @@ fn cleanup_one_managed_table_before_drop( Ok(mirror) } -/// Resolves table OIDs named by a `DROP TABLE` statement (missing_ok aware). +/// Resolves managed table OIDs targeted by a `DROP TABLE` or `DROP SCHEMA`. /// /// Uses `NoLock` so this hook does not hold relation locks while waiting for a /// concurrent flush to finish after cancel. @@ -144,9 +160,19 @@ fn cleanup_one_managed_table_before_drop( /// `stmt` must point at a live `DropStmt`. pub(super) unsafe fn drop_table_oids(stmt: *mut pg_sys::DropStmt) -> Vec { unsafe { - if stmt.is_null() || (*stmt).removeType != pg_sys::ObjectType::OBJECT_TABLE { + if stmt.is_null() { return Vec::new(); } + match (*stmt).removeType { + pg_sys::ObjectType::OBJECT_TABLE => drop_table_statement_oids(stmt), + pg_sys::ObjectType::OBJECT_SCHEMA => drop_schema_managed_table_oids(stmt), + _ => Vec::new(), + } + } +} + +unsafe fn drop_table_statement_oids(stmt: *mut pg_sys::DropStmt) -> Vec { + unsafe { let objects = (*stmt).objects; if objects.is_null() { return Vec::new(); @@ -187,3 +213,83 @@ pub(super) unsafe fn drop_table_oids(stmt: *mut pg_sys::DropStmt) -> Vec Vec { + unsafe { + let objects = (*stmt).objects; + if objects.is_null() { + return Vec::new(); + } + let missing_ok = (*stmt).missing_ok; + let mut oids = Vec::new(); + let count = (*objects).length as usize; + for index in 0..count { + let names = (*(*objects).elements.add(index)) + .ptr_value + .cast::(); + if names.is_null() { + continue; + } + let Some(schema_name) = name_list_to_string(names) else { + continue; + }; + oids.extend(active_managed_table_oids_in_schema( + &schema_name, + missing_ok, + )); + } + oids + } +} + +unsafe fn name_list_to_string(names: *mut pg_sys::List) -> Option { + unsafe { + let ptr = pg_sys::NameListToString(names); + if ptr.is_null() { + return None; + } + Some(CStr::from_ptr(ptr).to_string_lossy().into_owned()) + } +} + +fn active_managed_table_oids_in_schema(schema_name: &str, missing_ok: bool) -> Vec { + let Ok(c_name) = CString::new(schema_name) else { + return Vec::new(); + }; + let namespace = unsafe { pg_sys::get_namespace_oid(c_name.as_ptr(), missing_ok) }; + if namespace == pg_sys::InvalidOid { + return Vec::new(); + } + if !crate::catalog::cache::managed_catalog_ready() { + return Vec::new(); + } + pgrx::Spi::connect(|client| -> Result, String> { + let rows = client + .select( + "SELECT s.table_oid::oid \ + FROM koldstore.schemas s \ + JOIN pg_catalog.pg_class c ON c.oid = s.table_oid \ + WHERE s.active AND c.relnamespace = $1::oid \ + ORDER BY s.table_oid", + None, + &[DatumWithOid::from(namespace)], + ) + .map_err(|error| error.to_string())?; + let mut oids = Vec::new(); + for row in rows { + if let Some(table_oid) = row + .get::(1) + .map_err(|error| error.to_string())? + { + oids.push(table_oid); + } + } + Ok(oids) + }) + .unwrap_or_else(|error| { + pgrx::warning!( + "koldstore drop: failed to list managed tables in schema {schema_name}: {error}" + ); + Vec::new() + }) +} diff --git a/crates/pg_koldstore/src/hooks/executor.rs b/crates/pg_koldstore/src/hooks/executor.rs index 2619755c..93bceaec 100644 --- a/crates/pg_koldstore/src/hooks/executor.rs +++ b/crates/pg_koldstore/src/hooks/executor.rs @@ -59,27 +59,33 @@ mod live { // relation as the result target, so those writes are not missed. // Unmanaged DML in a database that happens to have a capture slot // must not wake maintenance or advance the logical slot. - let changed_managed_relation = changed_managed_relation(query_desc); + // Capture OIDs while QueryDesc is still live, but defer catalog/SPI + // lookup until the previous ExecutorEnd has closed the executor. + // Opening SPI before standard_ExecutorEnd can fail and used to turn + // managed DML into a silent false negative. + let changed_relation_oids = changed_relation_oids(query_desc); if let Some(previous) = PREVIOUS { previous(query_desc); } else { pg_sys::standard_ExecutorEnd(query_desc); } - if changed_managed_relation { + if changed_relation_oids + .into_iter() + .any(crate::catalog::cache::is_managed_relation) + { crate::worker::wake::mark_managed_dml_pending(); } crate::memory::release_process_heap_if_pending(); } } - unsafe fn changed_managed_relation(query_desc: *mut pg_sys::QueryDesc) -> bool { + unsafe fn changed_relation_oids(query_desc: *mut pg_sys::QueryDesc) -> Vec { unsafe { if query_desc.is_null() || (*query_desc).plannedstmt.is_null() || (*query_desc).estate.is_null() - || (*(*query_desc).estate).es_processed == 0 { - return false; + return Vec::new(); } if !matches!( (*query_desc).operation, @@ -88,15 +94,35 @@ mod live { | pg_sys::CmdType::CMD_DELETE | pg_sys::CmdType::CMD_MERGE ) { - return false; + return Vec::new(); } let planned = (*query_desc).plannedstmt; + let estate = (*query_desc).estate; + let mut relation_oids = Vec::new(); + + // PostgreSQL can leave PlannedStmt.resultRelations empty for some + // ModifyTable shapes. EState's opened result array is the executed + // source of truth and also covers routed partitions. + if !(*estate).es_result_relations.is_null() { + for index in 0..(*estate).es_range_table_size as usize { + let result_rel = *(*estate).es_result_relations.add(index); + if result_rel.is_null() || (*result_rel).ri_RelationDesc.is_null() { + continue; + } + let oid = (*(*result_rel).ri_RelationDesc).rd_id; + if !relation_oids.contains(&oid) { + relation_oids.push(oid); + } + } + } + let result_relations = (*planned).resultRelations; let rtable = (*planned).rtable; if result_relations.is_null() || rtable.is_null() { - return false; + return relation_oids; } + relation_oids.reserve((*result_relations).length as usize); for index in 0..(*result_relations).length as usize { let range_table_index = (*(*result_relations).elements.add(index)).int_value; if range_table_index <= 0 || range_table_index > (*rtable).length { @@ -107,12 +133,12 @@ mod live { .cast::(); if !rte.is_null() && (*rte).rtekind == pg_sys::RTEKind::RTE_RELATION - && crate::catalog::cache::is_managed_relation((*rte).relid) + && !relation_oids.contains(&(*rte).relid) { - return true; + relation_oids.push((*rte).relid); } } - false + relation_oids } } } diff --git a/crates/pg_koldstore/src/merge_scan/mod.rs b/crates/pg_koldstore/src/merge_scan/mod.rs index 46e67115..f77a8093 100644 --- a/crates/pg_koldstore/src/merge_scan/mod.rs +++ b/crates/pg_koldstore/src/merge_scan/mod.rs @@ -1,6 +1,6 @@ //! KoldMergeScan PostgreSQL glue. -pub use koldstore_merge::scan::{exec, path, plan}; +pub use koldstore_merge::scan::{path, plan}; #[cfg(feature = "pg")] pub mod pg; diff --git a/crates/pg_koldstore/src/merge_scan/pg.rs b/crates/pg_koldstore/src/merge_scan/pg.rs index 0a52583d..8cbebf11 100644 --- a/crates/pg_koldstore/src/merge_scan/pg.rs +++ b/crates/pg_koldstore/src/merge_scan/pg.rs @@ -43,7 +43,10 @@ use path_strategy::{ }; use profile::{ColdReadProfile, EmitPath, ScanExecutionProfile, ScanProfileSink, ScanProfiler}; use qual::{physical_scan_projection, required_scan_projection, residual_filters}; -use tuple::{slot_attribute_count, store_materialized_row, MaterializedRow, ScanMemory}; +use tuple::{ + clear_custom_scan_slots, clear_slot, slot_attribute_count, store_materialized_row, + MaterializedRow, ScanMemory, +}; use pg_list::{ list_cstring_at, list_integer_at, list_len, list_nth_ptr, make_pg_string, order_descending_flag, @@ -237,6 +240,8 @@ impl ScanExecutionState { let memory = memory .as_mut() .ok_or_else(|| "stream scan memory is unavailable".to_string())?; + // Drop slot aliases before resetting the AllocSet that owns them. + clear_slot(slot); memory.reset(); let Some(row) = stream.next_materialized( projection, @@ -291,6 +296,57 @@ pub fn register_custom_scan_hooks() { pg_sys::RegisterCustomScanMethods(&raw const SCAN_METHODS); PREVIOUS_SET_REL_PATHLIST_HOOK = pg_sys::set_rel_pathlist_hook; pg_sys::set_rel_pathlist_hook = Some(set_rel_pathlist); + pg_sys::RegisterXactCallback(Some(merge_scan_xact_callback), std::ptr::null_mut()); + pg_sys::RegisterSubXactCallback(Some(merge_scan_subxact_callback), std::ptr::null_mut()); + } +} + +/// Abandon thread-local scan state when PostgreSQL skips `EndCustomScan` on ERROR. +/// +/// Portal abort deletes the query AllocSet that [`ScanMemory`] wraps. Disowning +/// prevents a second `MemoryContextDelete` when the next statement drops the +/// stale [`SCAN_STATES`] entry (otherwise glibc aborts the backend). +fn abandon_scan_states_after_abort() { + SCAN_STATES.with(|states| { + let Ok(mut states) = states.try_borrow_mut() else { + // A longjmp left a live RefMut; force-clearing would panic under + // panic=abort. Leave the map; the next successful EndCustomScan or + // a later abort with a free borrow still scrubs. + return; + }; + for (_, mut scan) in states.drain() { + if let Some(memory) = scan.memory.as_mut() { + memory.disown(); + } + // Drop remaining Rust/Arrow state normally. + drop(scan); + } + }); + profile::abandon_completed_explain_states(); +} + +#[pgrx::pg_guard] +unsafe extern "C-unwind" fn merge_scan_xact_callback( + event: pg_sys::XactEvent::Type, + _arg: *mut std::ffi::c_void, +) { + match event { + pg_sys::XactEvent::XACT_EVENT_ABORT | pg_sys::XactEvent::XACT_EVENT_PARALLEL_ABORT => { + abandon_scan_states_after_abort(); + } + _ => {} + } +} + +#[pgrx::pg_guard] +unsafe extern "C-unwind" fn merge_scan_subxact_callback( + event: pg_sys::SubXactEvent::Type, + _my_subid: pg_sys::SubTransactionId, + _parent_subid: pg_sys::SubTransactionId, + _arg: *mut std::ffi::c_void, +) { + if event == pg_sys::SubXactEvent::SUBXACT_EVENT_ABORT_SUB { + abandon_scan_states_after_abort(); } } @@ -878,7 +934,11 @@ unsafe fn initialize_fallback_scan( memory, } = source_execution; - let hot_plan_label = hot_child_explain_label(node); + let hot_plan_label = if profiler.is_enabled() { + hot_child_explain_label(node) + } else { + String::new() + }; cold_profile.segments_opened = cold_profile.segments.len(); let execution = profiler.finish(hot_rows, scan_started); @@ -1001,19 +1061,21 @@ unsafe extern "C-unwind" fn next_scan_tuple( return std::ptr::null_mut(); } - let stored = SCAN_STATES.with(|states| { + // Resolve the Result outside the RefCell borrow so `pgrx::error!` (ereport / + // unwind) never longjmps while `SCAN_STATES` is borrowed. + let outcome = SCAN_STATES.with(|states| { let mut states = states.borrow_mut(); - let scan = states.get_mut(&(node as usize))?; - Some( - scan.store_next_row(slot) - .unwrap_or_else(|error| pgrx::error!("{CUSTOM_PATH_NAME} stream failed: {error}")), - ) + states + .get_mut(&(node as usize)) + .map(|scan| scan.store_next_row(slot)) }); - if stored == Some(true) { - slot - } else { - std::ptr::null_mut() + match outcome { + Some(Ok(true)) => slot, + Some(Ok(false)) | None => std::ptr::null_mut(), + Some(Err(error)) => { + pgrx::error!("{CUSTOM_PATH_NAME} stream failed: {error}") + } } } @@ -1033,6 +1095,9 @@ unsafe extern "C-unwind" fn end_custom_scan(node: *mut pg_sys::CustomScanState) (*provider).hot_probe, HotProbeState::Pending | HotProbeState::Hit ) { + // Clear slots before deleting ScanMemory: nodeCustom.c calls + // ExecClearTuple *after* EndCustomScan. + clear_custom_scan_slots(node); SCAN_STATES.with(|states| { if let Some(mut scan) = states.borrow_mut().remove(&(node as usize)) { if let Some(mut execution) = scan.execution.take() { diff --git a/crates/pg_koldstore/src/merge_scan/pg/cold.rs b/crates/pg_koldstore/src/merge_scan/pg/cold.rs index 0754ea6e..8a46fdd3 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/cold.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/cold.rs @@ -11,13 +11,14 @@ use koldstore_merge::scan::plan::{ validate_prune_predicates_indexed, ColdPruneColumnPolicy, SegmentPrunePredicate, SegmentStatsHint, }; +use koldstore_merge::scan::ColdProjectionPlan; use koldstore_parquet::{ - clean_cold_row_to_common, read_clean_cold_rows_from_object_store_with_size, ParquetReadOptions, - PgColumn, + clean_cold_row_to_common, read_clean_cold_rows_from_object_store_with_size, ParquetProfileMode, + ParquetReadOptions, PgColumn, }; use pgrx::pg_sys; -use super::profile::{elapsed_ms, ColdReadProfile, SegmentReadProfile}; +use super::profile::{elapsed_ms, ColdReadProfile, ProfileCollectionMode, SegmentReadProfile}; use super::qual::segment_prune_predicates; use super::with_hook_disabled; @@ -107,10 +108,8 @@ pub(super) struct ColdRowStream { segment_groups: Vec>, next_group: usize, projection_columns: Vec, - /// When set, OrderedProgressive may compete with a narrow column set first. - compete_columns: Option>, - /// Full projection minus compete (empty when late materialization is off). - body_columns: Vec, + /// Ordered compete/body split; absent when one full open is cheaper. + late_projection: Option, /// Cached migration catalog (shared; avoid cloning column metadata). catalog: std::sync::Arc, primary_key_columns: Vec, @@ -135,65 +134,73 @@ type SegmentIndexCandidateSpiRow = ( std::sync::Arc, ); +/// Borrowed PostgreSQL/catalog inputs for planning one lazy cold stream. +pub(super) struct ColdStreamPlanRequest<'a> { + pub(super) table_oid: pg_sys::Oid, + pub(super) scanrelid: pg_sys::Index, + pub(super) snapshot: &'a koldstore_catalog::ManagedTableSnapshot, + pub(super) catalog: &'a std::sync::Arc, + pub(super) qual: *mut pg_sys::List, + pub(super) projected_columns: &'a [&'a koldstore_migrate::order::CatalogColumn], + pub(super) params: pg_sys::ParamListInfo, + pub(super) profile_mode: ProfileCollectionMode, +} + +/// Inputs for decoding one catalog-selected group of cold segments. +struct ColdSegmentReadRequest<'a> { + client: &'a koldstore_storage::ObjectStoreClient, + segment_hints: &'a [SegmentStatsHint], + projected_columns: &'a [ColumnRef], + catalog_columns: &'a [koldstore_migrate::order::CatalogColumn], + primary_key_columns: &'a [ColumnRef], + current_schema_version: i32, + pk_probe: Option<&'a (ColumnRef, Vec)>, + profile_mode: ProfileCollectionMode, +} + impl ColdRowStream { /// Enables compete-then-body opens for OrderedProgressive when beneficial. /// /// Returns `true` when late materialization is armed. Narrow projections /// (compete ≈ full) fail open to a single Full open. pub(super) fn enable_late_materialization(&mut self, leading_column: &ColumnRef) -> bool { - let mut compete = self.primary_key_columns.clone(); - if !compete - .iter() - .any(|column| column.column_id == leading_column.column_id) - { - compete.push(leading_column.clone()); - } - compete.sort_by_key(|column| column.column_id); - compete.dedup_by_key(|column| column.column_id); - - let body: Vec = self - .projection_columns - .iter() - .filter(|column| { - !compete - .iter() - .any(|compete_column| compete_column.column_id == column.column_id) - }) - .cloned() - .collect(); - // Fail-open: no body left, or compete already includes everything useful. - if body.is_empty() || compete.len() >= self.projection_columns.len() { - self.compete_columns = None; - self.body_columns.clear(); - return false; - } - self.compete_columns = Some(compete); - self.body_columns = body; - true + self.late_projection = ColdProjectionPlan::for_ordered( + &self.projection_columns, + &self.primary_key_columns, + leading_column, + ); + self.late_projection.is_some() } /// True when OrderedProgressive should compete before hydrating body columns. #[must_use] pub(super) fn late_materialization_enabled(&self) -> bool { - self.compete_columns.is_some() && !self.body_columns.is_empty() + self.late_projection.is_some() } /// Application columns loaded during the compete phase (when armed). #[must_use] pub(super) fn compete_projection_names(&self) -> Vec { - self.compete_columns + self.late_projection .as_ref() - .map(|columns| columns.iter().map(|column| column.name.clone()).collect()) + .map(|plan| { + plan.compete() + .iter() + .map(|column| column.name.clone()) + .collect() + }) .unwrap_or_default() } /// Application body columns hydrated after compete (when armed). #[must_use] pub(super) fn body_projection_names(&self) -> Vec { - self.body_columns - .iter() - .map(|column| column.name.clone()) - .collect() + self.late_projection.as_ref().map_or_else(Vec::new, |plan| { + plan.body() + .iter() + .map(|column| column.name.clone()) + .collect() + }) } /// Reads the next overlapping segment group and closes every reader before @@ -201,25 +208,23 @@ impl ColdRowStream { pub(super) fn next_batch( &mut self, phase: ColdReadPhase, - collect_profile: bool, + profile_mode: ProfileCollectionMode, ) -> Result, String> { let Some(group) = self.segment_groups.get(self.next_group) else { return Ok(None); }; self.next_group += 1; let projected = self.columns_for_phase(phase); - let (rows, profiles) = cold_rows_from_segments( - &self.client, - group, - projected, - &self.catalog.columns, - &self.primary_key_columns, - self.schema_version, - &self.pk_probe, - )?; - if !collect_profile { - return Ok(Some((rows, Vec::new()))); - } + let (rows, profiles) = cold_rows_from_segments(ColdSegmentReadRequest { + client: &self.client, + segment_hints: group, + projected_columns: projected, + catalog_columns: &self.catalog.columns, + primary_key_columns: &self.primary_key_columns, + current_schema_version: self.schema_version, + pk_probe: self.pk_probe.as_ref(), + profile_mode, + })?; Ok(Some((rows, profiles))) } @@ -227,48 +232,40 @@ impl ColdRowStream { match phase { ColdReadPhase::Full => &self.projection_columns, ColdReadPhase::Compete => self - .compete_columns - .as_deref() + .late_projection + .as_ref() + .map(ColdProjectionPlan::compete) .unwrap_or(&self.projection_columns), } } /// Columns for a body hydrate open: body ∪ primary key (for join). fn body_read_columns(&self) -> Vec { - let mut columns = self.body_columns.clone(); - for pk in &self.primary_key_columns { - if !columns - .iter() - .any(|column| column.column_id == pk.column_id) - { - columns.push(pk.clone()); - } - } - columns + self.late_projection.as_ref().map_or_else(Vec::new, |plan| { + plan.body_with_primary_key(&self.primary_key_columns) + }) } /// Reads one body-hydrate group using body∪PK projection. pub(super) fn next_body_batch( &mut self, - collect_profile: bool, + profile_mode: ProfileCollectionMode, ) -> Result, String> { let Some(group) = self.segment_groups.get(self.next_group) else { return Ok(None); }; self.next_group += 1; let projected = self.body_read_columns(); - let (rows, profiles) = cold_rows_from_segments( - &self.client, - group, - &projected, - &self.catalog.columns, - &self.primary_key_columns, - self.schema_version, - &self.pk_probe, - )?; - if !collect_profile { - return Ok(Some((rows, Vec::new()))); - } + let (rows, profiles) = cold_rows_from_segments(ColdSegmentReadRequest { + client: &self.client, + segment_hints: group, + projected_columns: &projected, + catalog_columns: &self.catalog.columns, + primary_key_columns: &self.primary_key_columns, + current_schema_version: self.schema_version, + pk_probe: self.pk_probe.as_ref(), + profile_mode, + })?; Ok(Some((rows, profiles))) } @@ -309,10 +306,9 @@ impl ColdRowStream { continue; }; hint.selected_row_groups = Some(match hint.selected_row_groups.take() { - Some(existing) => existing - .into_iter() - .filter(|rg| selected.contains(rg)) - .collect(), + Some(existing) => { + koldstore_merge::scan::intersect_row_group_selections(existing, selected) + } None => selected, }); } @@ -323,26 +319,18 @@ impl ColdRowStream { /// Prepares a cold stream without opening or decoding a Parquet file. pub(super) fn prepare_cold_row_stream( - table_oid: pg_sys::Oid, - scanrelid: pg_sys::Index, - snapshot: &koldstore_catalog::ManagedTableSnapshot, - catalog: &std::sync::Arc, - qual: *mut pg_sys::List, - projected_columns: &[&koldstore_migrate::order::CatalogColumn], - params: pg_sys::ParamListInfo, + request: ColdStreamPlanRequest<'_>, ) -> Result<(ColdReadProfile, Option), String> { with_hook_disabled(|| { - let Some(planned) = plan_cold_segments( - table_oid, - scanrelid, - snapshot, - catalog.as_ref(), - qual, - projected_columns, - params, - )? - else { - return Ok((ColdReadProfile::empty("(none)"), None)); + let Some(planned) = plan_cold_segments(&request)? else { + return Ok(( + if request.profile_mode.collects_counts() { + ColdReadProfile::empty("(none)") + } else { + ColdReadProfile::disabled() + }, + None, + )); }; let mut profile = planned.profile; @@ -370,11 +358,10 @@ pub(super) fn prepare_cold_row_stream( segment_groups, next_group: 0, projection_columns: planned.projection_columns, - compete_columns: None, - body_columns: Vec::new(), - catalog: std::sync::Arc::clone(catalog), - primary_key_columns: snapshot.primary_key_columns.clone(), - schema_version: snapshot.schema_version, + late_projection: None, + catalog: std::sync::Arc::clone(request.catalog), + primary_key_columns: request.snapshot.primary_key_columns.clone(), + schema_version: request.snapshot.schema_version, pk_probe: planned.pk_probe, }), )) @@ -391,18 +378,23 @@ struct PlannedColdSegments { } fn plan_cold_segments( - table_oid: pg_sys::Oid, - scanrelid: pg_sys::Index, - snapshot: &koldstore_catalog::ManagedTableSnapshot, - catalog: &koldstore_migrate::ExistingTableCatalog, - qual: *mut pg_sys::List, - projected_columns: &[&koldstore_migrate::order::CatalogColumn], - params: pg_sys::ParamListInfo, + request: &ColdStreamPlanRequest<'_>, ) -> Result, String> { + let ColdStreamPlanRequest { + table_oid, + scanrelid, + snapshot, + catalog, + qual, + projected_columns, + params, + profile_mode, + } = request; + let catalog = catalog.as_ref(); let scope_column_id = snapshot.scope_column_id; let segment_order_column_id = snapshot.segment_order_column_id; let prune_predicates = retain_pre_merge_cold_prune_predicates( - unsafe { segment_prune_predicates(scanrelid, qual, catalog, params) }, + unsafe { segment_prune_predicates(*scanrelid, *qual, catalog, *params) }, |column_id| { let column = catalog.column_by_attnum(column_id)?; Some(cold_prune_column_policy( @@ -421,13 +413,13 @@ fn plan_cold_segments( })); requested_columns.sort_by_key(|column| column.column_id); requested_columns.dedup_by_key(|column| column.column_id); - let manifest_started = Instant::now(); + let manifest_started = profile_mode.collects_timing().then(Instant::now); let Some(manifest_stats) = - crate::catalog::cache::cached_manifest_scan_context(table_oid, &requested_columns)? + crate::catalog::cache::cached_manifest_scan_context(*table_oid, &requested_columns)? else { return Ok(None); }; - let manifest_read_ms = elapsed_ms(manifest_started); + let manifest_read_ms = manifest_started.map(elapsed_ms); let mut indexed_filter_column_ids = catalog .primary_key .columns @@ -446,13 +438,14 @@ fn plan_cold_segments( validate_prune_predicates_indexed(&prune_predicates, &indexed_filter_column_ids) .map_err(|error| error.to_string())?; let segments_considered = manifest_stats.segments.len(); - let index_started = Instant::now(); + let index_started = profile_mode.collects_timing().then(Instant::now); let resolved = resolve_segment_index_candidates( - table_oid, + *table_oid, manifest_stats.generation, catalog, segment_order_column_id, &prune_predicates, + profile_mode.collects_counts(), )?; let SegmentIndexCandidateResolution { candidates: indexed_candidates, @@ -464,7 +457,8 @@ fn plan_cold_segments( } = resolved; let segment_index_lookup_ms = indexed_candidates .as_ref() - .map(|_| elapsed_ms(index_started)); + .and(index_started) + .map(elapsed_ms); let segment_index_candidate_segments = indexed_candidates .as_ref() .map(|candidates| candidates.len()); @@ -472,39 +466,41 @@ fn plan_cold_segments( .map(|candidates| candidates.to_vec()) .unwrap_or_else(|| manifest_stats.segments.clone()); let segments_pruned_catalog_index = segments_considered.saturating_sub(segments.len()); - let projection = projection_columns - .iter() - .map(|column| column.name.clone()) - .collect::>(); let pk_probe = pk_equality_values(&prune_predicates, &snapshot.primary_key_columns); - let cold_segments_query = koldstore_catalog::queries::plan_in_sync_manifest_scan_context() - .ok() - .map(|statement| statement.sql); - let profile = ColdReadProfile { - manifest_path: manifest_stats.manifest_path(), - storage_type: manifest_stats.storage_type.clone(), - base_path: manifest_stats.base_path.clone(), - manifest_read_ms: Some(manifest_read_ms), - segments_considered, - segments_pruned_catalog_index, - segments_opened: segments.len(), - compete_opens: 0, - body_opens: 0, - compete_columns: Vec::new(), - body_columns: Vec::new(), - segment_index_order_column_id: index_column_id, - segment_index_order_column: index_column_name, - segment_index_lookup_shape: Some(segment_index_lookup_shape), - segment_index_plan, - segment_index_lookup_ms, - segment_index_candidate_segments, - cold_segments_query, - segment_index_query, - pk_probe: pk_probe - .as_ref() - .map(|(column, values)| (column.name.clone(), values.clone())), - projected_columns: projection, - segments: vec![], + let profile = if profile_mode.collects_counts() { + ColdReadProfile { + manifest_path: manifest_stats.manifest_path(), + storage_type: manifest_stats.storage_type.clone(), + base_path: manifest_stats.base_path.clone(), + manifest_read_ms, + segments_considered, + segments_pruned_catalog_index, + segments_opened: segments.len(), + compete_opens: 0, + body_opens: 0, + compete_columns: Vec::new(), + body_columns: Vec::new(), + segment_index_order_column_id: index_column_id, + segment_index_order_column: index_column_name, + segment_index_lookup_shape: Some(segment_index_lookup_shape), + segment_index_plan, + segment_index_lookup_ms, + segment_index_candidate_segments, + cold_segments_query: koldstore_catalog::queries::plan_in_sync_manifest_scan_context() + .ok() + .map(|statement| statement.sql), + segment_index_query, + pk_probe: pk_probe + .as_ref() + .map(|(column, values)| (column.name.clone(), values.clone())), + projected_columns: projection_columns + .iter() + .map(|column| column.name.clone()) + .collect(), + segments: Vec::new(), + } + } else { + ColdReadProfile::disabled() }; Ok(Some(PlannedColdSegments { profile, @@ -563,6 +559,7 @@ fn resolve_segment_index_candidates( catalog: &koldstore_migrate::ExistingTableCatalog, segment_order_column_id: Option, predicates: &[SegmentPrunePredicate], + collect_profile: bool, ) -> Result { let preferred = segment_order_column_id.and_then(|column_id| { catalog.column_by_id(column_id).filter(|column| { @@ -583,27 +580,35 @@ fn resolve_segment_index_candidates( }) }); let Some(column) = column else { - let order_name = segment_order_column_id.and_then(|column_id| { - catalog - .column_by_id(column_id) - .map(|column| column.name.clone()) + let order_name = collect_profile.then(|| { + segment_order_column_id.and_then(|column_id| { + catalog + .column_by_id(column_id) + .map(|column| column.name.clone()) + }) }); return Ok(SegmentIndexCandidateResolution { candidates: None, shape: SegmentIndexLookupShape::AllActive, column_id: segment_order_column_id.map(ColumnId::get), - column_name: order_name, + column_name: order_name.flatten(), plan: None, query: None, }); }; - let loaded = - load_segment_index_candidates(table_oid, manifest_generation, catalog, column, predicates)?; + let loaded = load_segment_index_candidates( + table_oid, + manifest_generation, + catalog, + column, + predicates, + collect_profile, + )?; Ok(SegmentIndexCandidateResolution { candidates: loaded.candidates, shape: loaded.shape, column_id: Some(column.column_id.get()), - column_name: Some(column.name.clone()), + column_name: collect_profile.then(|| column.name.clone()), plan: loaded.plan, query: loaded.query, }) @@ -615,6 +620,7 @@ fn load_segment_index_candidates( catalog: &koldstore_migrate::ExistingTableCatalog, column: &koldstore_migrate::order::CatalogColumn, predicates: &[SegmentPrunePredicate], + collect_profile: bool, ) -> Result { use pgrx::datum::DatumWithOid; @@ -646,8 +652,8 @@ fn load_segment_index_candidates( } }; let statement = statement.map_err(|error| error.to_string())?; - let query = Some(statement.sql.clone()); - let plan = Some(preferred_segment_index_access(shape).to_string()); + let query = collect_profile.then(|| statement.sql.clone()); + let plan = collect_profile.then(|| preferred_segment_index_access(shape).to_string()); let cache_key = crate::catalog::cache::SegmentIndexCandidateCacheKey::new( table_oid.to_u32(), @@ -1097,6 +1103,7 @@ pub(super) fn planned_cold_read_profile(table_oid: pg_sys::Oid) -> Result)>, + request: ColdSegmentReadRequest<'_>, ) -> Result<(Vec, Vec), String> { + let ColdSegmentReadRequest { + client, + segment_hints, + projected_columns, + catalog_columns, + primary_key_columns, + current_schema_version, + pk_probe, + profile_mode, + } = request; // One ObjectStore client for all segments (filesystem or S3). Parquet reads // are footer-first with range GETs — no full-object download. Known // `byte_size` enables bounded footer ranges (avoids suffix GETs on S3). @@ -1181,7 +1192,11 @@ fn cold_rows_from_segments( .map(|column| column.name.clone()) .collect::>(); let mut rows = Vec::new(); - let mut segments = Vec::with_capacity(segment_hints.len()); + let mut segments = if profile_mode.collects_counts() { + Vec::with_capacity(segment_hints.len()) + } else { + Vec::new() + }; for hint in segment_hints { // Columns added after a segment was written have no physical field in // that schema version; omit them from the Parquet projection and fill @@ -1233,7 +1248,12 @@ fn cold_rows_from_segments( .collect::, _>>()?; let mut options = ParquetReadOptions::new() .with_columns(columns.iter().map(|column| column.name.clone())) - .with_timeout(client.timeout()); + .with_timeout(client.timeout()) + .with_profile_mode(match profile_mode { + ProfileCollectionMode::Disabled => ParquetProfileMode::Disabled, + ProfileCollectionMode::Counts => ParquetProfileMode::Counts, + ProfileCollectionMode::CountsAndTiming => ParquetProfileMode::CountsAndTiming, + }); if let Some(row_groups) = &hint.selected_row_groups { options = options.with_row_groups(row_groups.iter().copied()); } @@ -1247,10 +1267,12 @@ fn cold_rows_from_segments( })?; options = options.with_pk_values(physical_pk, values.iter().cloned()); } - let started = Instant::now(); + let started = profile_mode.collects_timing().then(Instant::now); + let permit_started = profile_mode.collects_timing().then(Instant::now); let _permit = crate::merge_scan::reader_pool::try_acquire_parquet_reader_permit( crate::guc::max_open_parquet_readers(), )?; + let reader_pool_wait_ms = permit_started.map(elapsed_ms); let (segment_rows, parquet_profile) = read_clean_cold_rows_from_object_store_with_size( std::sync::Arc::clone(&store), &hint.object_path, @@ -1259,13 +1281,16 @@ fn cold_rows_from_segments( &physical_pk_names, &options, )?; - segments.push(SegmentReadProfile { - object_path: hint.object_path.clone(), - row_count: segment_rows.len(), - read_ms: Some(elapsed_ms(started)), - byte_size: hint.byte_size.or(parquet_profile.file_size), - parquet: Some(parquet_profile), - }); + if profile_mode.collects_counts() { + segments.push(SegmentReadProfile { + object_path: hint.object_path.clone(), + row_count: segment_rows.len(), + reader_pool_wait_ms, + read_ms: started.map(elapsed_ms), + byte_size: hint.byte_size.or(parquet_profile.file_size), + parquet: Some(parquet_profile), + }); + } for mut row in segment_rows { remap_json_to_logical_names(&mut row.pk_json, &physical_names); remap_row_to_logical_names(&mut row.row_image, &physical_names); diff --git a/crates/pg_koldstore/src/merge_scan/pg/execute.rs b/crates/pg_koldstore/src/merge_scan/pg/execute.rs index 7419c7ac..cf204cc3 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/execute.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/execute.rs @@ -8,23 +8,20 @@ use std::collections::VecDeque; use std::time::Instant; use koldstore_merge::scan::{hot_keys_dominate_bound, OrderDirection}; -use koldstore_merge::{NewestFirstWinnerResolver, ResolvedRow, RowSource}; +use koldstore_merge::{MirrorOverlay, NewestFirstWinnerResolver, ResolvedRow, RowSource}; use koldstore_migrate::{order::CatalogColumn, ExistingTableCatalog}; use pgrx::pg_sys; -use super::cold::{prepare_cold_row_stream, ColdReadPhase, ColdRowStream}; +use super::cold::{prepare_cold_row_stream, ColdReadPhase, ColdRowStream, ColdStreamPlanRequest}; use super::cold_frontier; use super::emit::materialize_scan_row_from_image; use super::hot::{load_hot_rows_native, HotEqualityFilter, HotMergeBatchReader, HotRangeFilter}; use super::hot_cursor::{HotMergeSource, NativeHotCursor}; -use super::mirror::{ - filter_cold_rows_with_overlay, load_mirror_tombstone_overlay, load_mirror_tombstones_for_pks, - MirrorOverlay, -}; +use super::mirror::{load_mirror_tombstone_overlay, load_mirror_tombstones_for_pks}; use super::path_strategy::{STRATEGY_TAG_ORDERED_PROGRESSIVE, STRATEGY_TAG_UNORDERED_HOT_FIRST}; use super::profile::{ - elapsed_ms, ColdReadProfile, DisabledScanProfiler, EmitPath, ScanExecutionProfile, - ScanProfileSink, ScanProfiler, + elapsed_ms, ColdReadProfile, DisabledScanProfiler, EmitPath, ProfileCollectionMode, + ScanExecutionProfile, ScanProfileSink, ScanProfiler, }; use super::qual::ScanProjection; use super::tuple::{MaterializedRow, ScanMemory}; @@ -232,7 +229,7 @@ impl MergeRowStream { } if !self.replay_hot { self.resolver - .mask_older_pks(self.overlay.masked_pks.iter().cloned()) + .mask_older_pks(self.overlay.iter().cloned()) .map_err(seen_key_limit_error)?; self.resolver.checkpoint(); } @@ -248,9 +245,9 @@ impl MergeRowStream { .map(Some); } - let collect_profile = execution.is_some(); + let profile_mode = execution_profile_mode(execution.as_deref()); let Some((cold_rows, segment_profiles)) = - self.cold.next_batch(ColdReadPhase::Full, collect_profile)? + self.cold.next_batch(ColdReadPhase::Full, profile_mode)? else { return Ok(None); }; @@ -267,12 +264,11 @@ impl MergeRowStream { self.probe_overlay_for_cold_batch(&cold_rows, execution.as_deref_mut())?; } - let overlay_input = cold_rows.len(); - let overlay_started = execution.as_ref().map(|_| Instant::now()); - let cold_rows = filter_cold_rows_with_overlay(cold_rows, &self.overlay); - let overlay_removed = overlay_input.saturating_sub(cold_rows.len()); + let overlay_started = execution_timer(execution.as_deref()); + let mut cold_rows = cold_rows; + let overlay_removed = self.overlay.retain_unmasked(&mut cold_rows); let merge_input = cold_rows.len(); - let merge_started = execution.as_ref().map(|_| Instant::now()); + let merge_started = execution_timer(execution.as_deref()); let winners = self .resolver .resolve_cold_batch(cold_rows) @@ -328,8 +324,8 @@ impl MergeRowStream { koldstore_common::RowImage, > = std::collections::HashMap::new(); loop { - let collect_profile = execution.is_some(); - let Some((cold_rows, segment_profiles)) = self.cold.next_body_batch(collect_profile)? + let profile_mode = execution_profile_mode(execution.as_deref()); + let Some((cold_rows, segment_profiles)) = self.cold.next_body_batch(profile_mode)? else { break; }; @@ -434,9 +430,9 @@ impl MergeRowStream { ColdReadPhase::Full }; loop { - let collect_profile = execution.is_some(); + let profile_mode = execution_profile_mode(execution.as_deref()); let Some((cold_rows, segment_profiles)) = - self.cold.next_batch(cold_phase, collect_profile)? + self.cold.next_batch(cold_phase, profile_mode)? else { break; }; @@ -455,8 +451,8 @@ impl MergeRowStream { self.probe_overlay_for_cold_batch(&cold_rows, execution.as_deref_mut())?; } let overlay_input = cold_rows.len(); - let cold_rows = filter_cold_rows_with_overlay(cold_rows, &self.overlay); - let overlay_removed = overlay_input.saturating_sub(cold_rows.len()); + let mut cold_rows = cold_rows; + let overlay_removed = self.overlay.retain_unmasked(&mut cold_rows); let winners = self .resolver .resolve_cold_batch(cold_rows) @@ -509,27 +505,26 @@ impl MergeRowStream { }; let mut unseen = Vec::new(); for row in cold_rows { - if !self.overlay.masked_pks.contains(&row.pk) { + if !self.overlay.contains(&row.pk) { unseen.push(row.pk.clone()); } } if unseen.is_empty() { return Ok(()); } - let started = execution.as_ref().map(|_| Instant::now()); + let started = execution_timer(execution.as_deref()); let batch = load_mirror_tombstones_for_pks( &deferred.mirror_relation, &deferred.primary_key_columns, &unseen, )?; if let Some(execution) = execution { - execution.mirror_rows = execution.mirror_rows.saturating_add(batch.tombstones); + execution.mirror_rows = execution.mirror_rows.saturating_add(batch.len()); // Deferred PK probes are the ordered/unordered mirror scan phase. accumulate_ms(&mut execution.mirror_scan_ms, started); } - for pk in batch.masked_pks { - self.overlay.masked_pks.insert(pk); - self.overlay.tombstones = self.overlay.masked_pks.len(); + for pk in batch.into_masked_pks() { + self.overlay.insert(pk); } Ok(()) } @@ -538,12 +533,12 @@ impl MergeRowStream { &mut self, execution: Option<&mut ScanExecutionProfile>, ) -> Result<(), String> { - let started = execution.as_ref().map(|_| Instant::now()); + let started = execution_timer(execution.as_deref()); let Some(rows) = self.hot.next_batch()? else { return Ok(()); }; let fetched = rows.len(); - let merge_started = execution.as_ref().map(|_| Instant::now()); + let merge_started = execution_timer(execution.as_deref()); let winners = if self.replay_hot { rows.into_iter().map(hot_row_as_resolved).collect() } else { @@ -610,7 +605,7 @@ unsafe fn materialize_owned_row( memory: &mut ScanMemory, execution: Option<&mut ScanExecutionProfile>, ) -> Result { - let started = execution.as_ref().map(|_| Instant::now()); + let started = execution_timer(execution.as_deref()); let materialized = memory.switch(|| materialize_scan_row_from_image(&row.row_image, projection)); if let Some(execution) = execution { @@ -628,6 +623,20 @@ fn accumulate_ms(total: &mut Option, started: Option) { *total = Some(total.unwrap_or(0.0) + elapsed_ms(started)); } +#[inline] +fn execution_profile_mode(execution: Option<&ScanExecutionProfile>) -> ProfileCollectionMode { + execution.map_or(ProfileCollectionMode::Disabled, |profile| { + profile.collection_mode() + }) +} + +#[inline] +fn execution_timer(execution: Option<&ScanExecutionProfile>) -> Option { + execution_profile_mode(execution) + .collects_timing() + .then(Instant::now) +} + /// Selects and executes the hot, cold, mirror, and winner-resolution paths. /// /// PostgreSQL errors abort the active backend invocation; successful execution @@ -657,16 +666,15 @@ unsafe fn execute_scan_sources_with_profile( // Full-PK probes run before Parquet opens. A hot winner makes every older // cold version irrelevant and keeps the common point-hit path hot-only. if let Some(rows) = probe_hot_point_hit(&inputs, &mut memory, profiler) { - return hot_buffer_execution( - rows, - ColdReadProfile::empty("(none)"), - &inputs, - memory, - profiler, - ); + let cold_profile = if profiler.collection_mode().collects_counts() { + ColdReadProfile::empty("(none)") + } else { + ColdReadProfile::disabled() + }; + return hot_buffer_execution(rows, cold_profile, &inputs, memory, profiler); } - let (mut cold_profile, cold_stream) = prepare_cold_stream(&inputs); + let (mut cold_profile, cold_stream) = prepare_cold_stream(&inputs, profiler.collection_mode()); let has_no_cold_source = cold_stream.is_none(); if has_no_cold_source { initialize_custom_plan_children(inputs.node, inputs.estate, inputs.eflags); @@ -766,16 +774,20 @@ fn hot_buffer_execution( } #[inline(always)] -fn prepare_cold_stream(inputs: &ScanSourceInputs<'_>) -> (ColdReadProfile, Option) { - prepare_cold_row_stream( - inputs.table_oid, - inputs.scanrelid, - inputs.snapshot, - &inputs.catalog, - inputs.qual, - inputs.image_columns, - inputs.params, - ) +fn prepare_cold_stream( + inputs: &ScanSourceInputs<'_>, + profile_mode: ProfileCollectionMode, +) -> (ColdReadProfile, Option) { + prepare_cold_row_stream(ColdStreamPlanRequest { + table_oid: inputs.table_oid, + scanrelid: inputs.scanrelid, + snapshot: inputs.snapshot, + catalog: &inputs.catalog, + qual: inputs.qual, + projected_columns: inputs.image_columns, + params: inputs.params, + profile_mode, + }) .unwrap_or_else(|error| pgrx::error!("{CUSTOM_PATH_NAME} cold stream setup failed: {error}")) } @@ -825,8 +837,10 @@ fn prepare_merged_stream( ) .unwrap_or_else(|error| pgrx::error!("{CUSTOM_PATH_NAME} hot reader setup failed: {error}")); let hot = HotMergeSource::SpiJson(hot); - if let Some(sql) = hot.first_page_sql() { - profiler.record_hot_spi_query(sql); + if profiler.collection_mode().collects_counts() { + if let Some(sql) = hot.first_page_sql() { + profiler.record_hot_spi_query(sql); + } } // Hot pages load during ExecCustomScan; EXPLAIN counters accumulate there. profiler.record_hot_buffer(0); @@ -861,8 +875,10 @@ fn prepare_unordered_hot_first_stream( .unwrap_or_else(|error| { pgrx::error!("{CUSTOM_PATH_NAME} unordered hot-first SPI fallback failed: {error}") }); - if let Some(sql) = reader.first_page_sql() { - profiler.record_hot_spi_query(sql); + if profiler.collection_mode().collects_counts() { + if let Some(sql) = reader.first_page_sql() { + profiler.record_hot_spi_query(sql); + } } HotMergeSource::SpiJson(reader) } @@ -905,8 +921,10 @@ fn prepare_ordered_merged_stream( .unwrap_or_else(|error| { pgrx::error!("{CUSTOM_PATH_NAME} ordered SPI fallback failed: {error}") }); - if let Some(sql) = reader.first_page_sql() { - profiler.record_hot_spi_query(sql); + if profiler.collection_mode().collects_counts() { + if let Some(sql) = reader.first_page_sql() { + profiler.record_hot_spi_query(sql); + } } HotMergeSource::SpiJson(reader) } @@ -937,7 +955,9 @@ fn prepare_ordered_merged_stream( }); if let Some(leading) = inputs.catalog.column_by_attnum(leading_column_id) { let leading_ref = koldstore_common::ColumnRef::new(leading.column_id, leading.name.clone()); - if cold_stream.enable_late_materialization(&leading_ref) { + if cold_stream.enable_late_materialization(&leading_ref) + && profiler.collection_mode().collects_counts() + { cold_profile.compete_columns = cold_stream.compete_projection_names(); cold_profile.body_columns = cold_stream.body_projection_names(); } @@ -1026,6 +1046,6 @@ fn load_overlay( inputs.pk_equality, ) .unwrap_or_else(|error| pgrx::error!("{CUSTOM_PATH_NAME} mirror overlay failed: {error}")); - profiler.record_mirror_scan(overlay.tombstones, started); + profiler.record_mirror_scan(overlay.len(), started); overlay } diff --git a/crates/pg_koldstore/src/merge_scan/pg/hot_cursor.rs b/crates/pg_koldstore/src/merge_scan/pg/hot_cursor.rs index 3b83aa4f..e6629b8e 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/hot_cursor.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/hot_cursor.rs @@ -19,7 +19,7 @@ use koldstore_migrate::order::CatalogColumn; use pgrx::pg_sys; use super::hot::HotMergeBatchReader; -use super::literals::datum_to_cell_value; +use super::literals::{datum_to_cell_value, unwrap_relabel}; use super::pg_list::{list_len, list_nth_ptr}; use super::{exec_proc_node, tuple_slot_is_empty, with_hook_disabled}; @@ -327,18 +327,6 @@ unsafe fn var_attnum(expr: *mut pg_sys::Expr) -> Option { (attnum > 0).then_some(attnum) } -unsafe fn unwrap_relabel(expr: *mut pg_sys::Expr) -> *mut pg_sys::Expr { - if expr.is_null() { - return expr; - } - if (*expr).type_ == pg_sys::NodeTag::T_RelabelType { - let relabel = expr.cast::(); - (*relabel).arg.cast::() - } else { - expr - } -} - unsafe fn ensure_slot_attrs(slot: *mut pg_sys::TupleTableSlot, attnum: i16) { if slot.is_null() || attnum <= 0 { return; diff --git a/crates/pg_koldstore/src/merge_scan/pg/mirror.rs b/crates/pg_koldstore/src/merge_scan/pg/mirror.rs index 4f351721..674e640d 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/mirror.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/mirror.rs @@ -6,49 +6,14 @@ use std::collections::HashSet; -use koldstore_common::{quote_ident, ColdRow, ColumnRef, LogicalPk, PkColumn, TableName}; +use koldstore_common::{quote_ident, ColumnRef, LogicalPk, PkColumn, TableName}; +use koldstore_merge::MirrorOverlay; use pgrx::pg_sys; use super::hot::HotEqualityFilter; use super::spi_query::with_read_query; use super::with_hook_disabled; -/// Mirror tombstones that must mask cold Parquet rows until flush. -#[derive(Debug, Default, Clone)] -pub(super) struct MirrorOverlay { - /// Unflushed tombstone PKs (`op = 3`), keyed as [`LogicalPk`]. - pub masked_pks: HashSet, - /// Count of tombstone (op = 3) rows in the overlay. - pub tombstones: usize, -} - -impl MirrorOverlay { - /// Returns true when cold state for this PK must be skipped. - #[must_use] - pub(super) fn masks_pk(&self, pk: &LogicalPk) -> bool { - self.masked_pks.contains(pk) - } - - #[must_use] - pub(super) fn is_empty(&self) -> bool { - self.masked_pks.is_empty() - } -} - -/// Removes cold rows masked by unflushed mirror tombstones. -pub(super) fn filter_cold_rows_with_overlay( - cold_rows: Vec, - overlay: &MirrorOverlay, -) -> Vec { - if overlay.is_empty() { - return cold_rows; - } - cold_rows - .into_iter() - .filter(|row| !overlay.masks_pk(&row.pk)) - .collect() -} - /// Loads mirror tombstones that can mask cold rows for this scan. /// /// When `pk_filters` contains primary-key equality predicates, only those keys @@ -184,8 +149,7 @@ unsafe fn execute_mirror_overlay_query( .map_err(|error| format!("mirror overlay pk JSON: {error}"))?; let pk = LogicalPk::from_json_object(&pk_value, pk_columns) .map_err(|error| error.to_string())?; - overlay.tombstones += 1; - overlay.masked_pks.insert(pk); + overlay.insert(pk); } } Ok(overlay) diff --git a/crates/pg_koldstore/src/merge_scan/pg/profile.rs b/crates/pg_koldstore/src/merge_scan/pg/profile.rs index 4d0fe79d..df0bd34a 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/profile.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/profile.rs @@ -58,6 +58,7 @@ pub(super) struct ScanProfiler { /// The uninstrumented implementation is a zero-sized no-op, allowing LLVM to /// remove every profiling call from ordinary query execution. pub(super) trait ScanProfileSink { + fn collection_mode(&self) -> ProfileCollectionMode; fn start_timer(&self) -> Option; fn record_hot_scan(&mut self, started: Option); fn record_hot_buffer(&mut self, row_count: usize); @@ -69,6 +70,11 @@ pub(super) trait ScanProfileSink { pub(super) struct DisabledScanProfiler; impl ScanProfileSink for DisabledScanProfiler { + #[inline(always)] + fn collection_mode(&self) -> ProfileCollectionMode { + ProfileCollectionMode::Disabled + } + #[inline(always)] fn start_timer(&self) -> Option { None @@ -100,9 +106,12 @@ impl ScanProfiler { ); Self { collection, - execution: collection - .collects_counts() - .then(|| Box::new(ScanExecutionProfile::default())), + execution: collection.collects_counts().then(|| { + Box::new(ScanExecutionProfile { + collect_timing: collection.collects_timing(), + ..ScanExecutionProfile::default() + }) + }), } } @@ -134,6 +143,11 @@ impl ScanProfiler { } impl ScanProfileSink for ScanProfiler { + #[inline] + fn collection_mode(&self) -> ProfileCollectionMode { + self.collection + } + #[inline] fn start_timer(&self) -> Option { self.collection.collects_timing().then(Instant::now) @@ -200,6 +214,8 @@ impl EmitPath { /// Execution counters and phase timings for one KoldMergeScan invocation. #[derive(Debug, Clone, Default)] pub(super) struct ScanExecutionProfile { + /// Whether PostgreSQL requested wall-clock timings as well as counters. + collect_timing: bool, /// Rows read from the hot heap, including a zero-row point probe. pub(super) hot_rows: usize, /// Maximum hot JSON rows retained in one MergeStream SPI page. @@ -228,6 +244,7 @@ pub(super) struct ScanExecutionProfile { pub(super) initialization_ms: Option, /// Managed-table metadata lookup and scan-shape construction. pub(super) metadata_ms: Option, + /// Hot SPI scan or point-probe time. Native child timing remains on its plan node. pub(super) hot_scan_ms: Option, /// Mirror tombstone SPI scan time. @@ -240,6 +257,17 @@ pub(super) struct ScanExecutionProfile { pub(super) materialization_ms: Option, } +impl ScanExecutionProfile { + #[inline] + pub(super) const fn collection_mode(&self) -> ProfileCollectionMode { + if self.collect_timing { + ProfileCollectionMode::CountsAndTiming + } else { + ProfileCollectionMode::Counts + } + } +} + /// Execution metadata retained until PostgreSQL invokes the EXPLAIN callback. #[derive(Debug)] pub(super) struct CompletedExplainState { @@ -273,10 +301,21 @@ pub(super) fn take_completed_explain_state(node_key: usize) -> Option, pub(super) read_ms: Option, /// Catalog object size when known. pub(super) byte_size: Option, @@ -332,6 +371,11 @@ pub(super) struct ColdReadProfile { } impl ColdReadProfile { + /// Empty placeholder retained by uninstrumented executor state. + pub(super) fn disabled() -> Self { + Self::empty(String::new()) + } + pub(super) fn empty(manifest_path: impl Into) -> Self { Self { manifest_path: manifest_path.into(), @@ -374,6 +418,42 @@ impl ColdReadProfile { } any.then_some(total) } + + fn reader_pool_wait_ms(&self) -> Option { + self.segments + .iter() + .filter_map(|segment| segment.reader_pool_wait_ms) + .reduce(|total, wait| total + wait) + } + + fn parquet_open_ms(&self) -> Option { + self.sum_parquet_duration(|parquet| parquet.open_duration) + } + + fn parquet_scan_ms(&self) -> Option { + self.sum_parquet_duration(|parquet| parquet.scan_duration) + } + + fn object_store_read_ms(&self) -> Option { + self.sum_parquet_duration(|parquet| parquet.object_store_read_duration) + } + + fn sum_parquet_duration( + &self, + duration: impl Fn(&ParquetReadProfile) -> std::time::Duration, + ) -> Option { + let mut total = std::time::Duration::ZERO; + let mut any = false; + for parquet in self + .segments + .iter() + .filter_map(|segment| segment.parquet.as_ref()) + { + total = total.saturating_add(duration(parquet)); + any = true; + } + any.then_some(total.as_secs_f64() * 1000.0) + } } pub(super) fn elapsed_ms(started: Instant) -> f64 { @@ -462,8 +542,10 @@ pub(super) fn explain_visual_pipeline( "Hot Actual Access", hot_actual_access(emit_path, used_spi), ); - if let Some(sql) = execution.hot_spi_query.as_deref() { - explain_text(es, "Hot SPI Query", &compact_sql_for_explain(sql)); + if explain_shows_diagnostics(es) { + if let Some(sql) = execution.hot_spi_query.as_deref() { + explain_text(es, "Hot SPI Query", &compact_sql_for_explain(sql)); + } } explain_integer(es, "Rows Scanned", None, execution.hot_rows as i64); if show_timing { @@ -480,7 +562,7 @@ pub(super) fn explain_visual_pipeline( explain_visual_node_close(es, "KoldStore Hot Scan"); explain_visual_node_open(es, "KoldStore Cold Storage Scan", "Cold Source"); - let cold_accessed = profile.manifest_read_ms.is_some(); + let cold_accessed = cold_scan_accessed(profile, execution); explain_text( es, "Status", @@ -550,7 +632,7 @@ fn explain_visual_catalog_node( es, "Status", if analyze { - if profile.manifest_read_ms.is_some() { + if cold_scan_accessed(profile, None) { "executed" } else { "not executed" @@ -649,6 +731,15 @@ fn explain_visual_parquet_io_stages( explain_uinteger(es, "Object Bytes", Some("bytes"), size); } explain_text(es, "Status", if executed { "executed" } else { "planned" }); + if executed && show_timing { + explain_float( + es, + "Actual Total Time", + "ms", + parquet.open_duration.as_secs_f64() * 1000.0, + 3, + ); + } explain_visual_node_close(es, "KoldStore Parquet Footer"); explain_visual_node_open( @@ -706,9 +797,13 @@ fn explain_visual_parquet_io_stages( if executed { explain_integer(es, "Rows Returned", None, parquet.rows_returned as i64); if show_timing { - if let Some(ms) = segment.read_ms { - explain_float(es, "Actual Total Time", "ms", ms, 3); - } + explain_float( + es, + "Actual Total Time", + "ms", + parquet.scan_duration.as_secs_f64() * 1000.0, + 3, + ); } } if !parquet.projected_columns.is_empty() { @@ -745,6 +840,18 @@ fn explain_visual_timing_node( if let Some(ms) = profile.cold_read_ms() { explain_float(es, "Cold Read Time", "ms", ms, 3); } + if let Some(ms) = profile.reader_pool_wait_ms() { + explain_float(es, "Reader Pool Wait Time", "ms", ms, 3); + } + if let Some(ms) = profile.parquet_open_ms() { + explain_float(es, "Parquet Open Time", "ms", ms, 3); + } + if let Some(ms) = profile.object_store_read_ms() { + explain_float(es, "Object Store Read Time", "ms", ms, 3); + } + if let Some(ms) = profile.parquet_scan_ms() { + explain_float(es, "Parquet Scan Time", "ms", ms, 3); + } if let Some(ms) = execution.mirror_scan_ms { explain_float(es, "Mirror Scan Time", "ms", ms, 3); } @@ -828,8 +935,10 @@ fn explain_hot_scan( Some(execution) => { let used_spi = execution.hot_spi_query.is_some(); explain_text(es, "Actual Access", hot_actual_access(emit_path, used_spi)); - if let Some(sql) = execution.hot_spi_query.as_deref() { - explain_text(es, "Hot SPI Query", &compact_sql_for_explain(sql)); + if explain_shows_diagnostics(es) { + if let Some(sql) = execution.hot_spi_query.as_deref() { + explain_text(es, "Hot SPI Query", &compact_sql_for_explain(sql)); + } } explain_integer(es, "Rows Scanned", None, execution.hot_rows as i64); if matches!( @@ -881,7 +990,7 @@ fn explain_cold_scan( // carries placeholder timing (e.g. manifest_read_ms: Some(0.0)). let analyze = explain_is_analyze(es); explain_open_group(es, "Cold Scan", Some("Cold Scan"), true); - let cold_accessed = profile.manifest_read_ms.is_some(); + let cold_accessed = cold_scan_accessed(profile, execution); let status = if analyze { if cold_accessed { "executed" @@ -1031,11 +1140,13 @@ fn explain_cold_scan( ); explain_bool(es, "Runtime Manifest Read", false); } - if let Some(query) = profile.cold_segments_query.as_deref() { - explain_text(es, "Cold Segments Query", &compact_sql_for_explain(query)); - } - if let Some(query) = profile.segment_index_query.as_deref() { - explain_text(es, "Segment Index Query", &compact_sql_for_explain(query)); + if explain_shows_diagnostics(es) { + if let Some(query) = profile.cold_segments_query.as_deref() { + explain_text(es, "Cold Segments Query", &compact_sql_for_explain(query)); + } + if let Some(query) = profile.segment_index_query.as_deref() { + explain_text(es, "Segment Index Query", &compact_sql_for_explain(query)); + } } if !profile.storage_type.is_empty() { @@ -1056,13 +1167,15 @@ fn explain_cold_scan( // Nested group for JSON/YAML/XML graph clients; TEXT uses a native "Label:\n" // section header. Empty arrays still emit so structured clients see a stable key. - explain_open_group(es, "Parquet Segments", Some("Parquet Segments"), false); - for segment in &profile.segments { - explain_open_group(es, "Parquet Segment", None, true); - explain_segment(es, segment, show_timing, analyze); - explain_close_group(es, "Parquet Segment", None, true); + if explain_shows_diagnostics(es) { + explain_open_group(es, "Parquet Segments", Some("Parquet Segments"), false); + for segment in &profile.segments { + explain_open_group(es, "Parquet Segment", None, true); + explain_segment(es, segment, show_timing, analyze); + explain_close_group(es, "Parquet Segment", None, true); + } + explain_close_group(es, "Parquet Segments", Some("Parquet Segments"), false); } - explain_close_group(es, "Parquet Segments", Some("Parquet Segments"), false); explain_close_group(es, "Cold Scan", Some("Cold Scan"), true); } @@ -1178,6 +1291,18 @@ fn explain_timing_group( if let Some(ms) = cold_ms { explain_float(es, "Cold Read Time", "ms", ms, 3); } + if let Some(ms) = profile.reader_pool_wait_ms() { + explain_float(es, "Reader Pool Wait Time", "ms", ms, 3); + } + if let Some(ms) = profile.parquet_open_ms() { + explain_float(es, "Parquet Open Time", "ms", ms, 3); + } + if let Some(ms) = profile.object_store_read_ms() { + explain_float(es, "Object Store Read Time", "ms", ms, 3); + } + if let Some(ms) = profile.parquet_scan_ms() { + explain_float(es, "Parquet Scan Time", "ms", ms, 3); + } if let Some(ms) = execution.mirror_scan_ms { explain_float(es, "Mirror Scan Time", "ms", ms, 3); } @@ -1212,6 +1337,9 @@ fn explain_segment( if let Some(ms) = segment.read_ms { explain_float(es, "Read Time", "ms", ms, 3); } + if let Some(ms) = segment.reader_pool_wait_ms { + explain_float(es, "Reader Pool Wait Time", "ms", ms, 3); + } } } else { explain_text(es, "Status", "planned"); @@ -1220,6 +1348,29 @@ fn explain_segment( let Some(parquet) = &segment.parquet else { return; }; + if executed && show_timing { + explain_float( + es, + "Open Time", + "ms", + parquet.open_duration.as_secs_f64() * 1000.0, + 3, + ); + explain_float( + es, + "Object Store Read Time", + "ms", + parquet.object_store_read_duration.as_secs_f64() * 1000.0, + 3, + ); + explain_float( + es, + "Scan Time", + "ms", + parquet.scan_duration.as_secs_f64() * 1000.0, + 3, + ); + } explain_bool(es, "Footer First", parquet.footer_first); explain_bool(es, "Footer Cache Hit", parquet.footer_cache_hit); explain_uinteger(es, "Range Gets", None, parquet.range_calls); @@ -1358,6 +1509,33 @@ fn explain_wants_timing(es: *mut pg_sys::ExplainState) -> bool { unsafe { (*es).timing } } +/// True when ANALYZE observed cold catalog and/or Parquet work. +/// +/// Prefer durable counters over timing fields so `EXPLAIN (ANALYZE, TIMING OFF)` +/// still reports `Status: executed` after a real cold read. +fn cold_scan_accessed(profile: &ColdReadProfile, execution: Option<&ScanExecutionProfile>) -> bool { + if profile.manifest_read_ms.is_some() + || profile.segments_opened > 0 + || profile.bytes_fetched() > 0 + { + return true; + } + let (row_groups_total, _, _, _) = profile.row_group_totals(); + if row_groups_total > 0 { + return true; + } + execution.is_some_and(|execution| execution.cold_rows > 0) +} + +/// TEXT plans reserve raw SQL and per-file diagnostics for EXPLAIN VERBOSE. +/// Structured formats retain their stable nested graph contract. +fn explain_shows_diagnostics(es: *mut pg_sys::ExplainState) -> bool { + if es.is_null() { + return false; + } + unsafe { (*es).verbose || (*es).format != pg_sys::ExplainFormat::EXPLAIN_FORMAT_TEXT } +} + pub(super) fn explain_integer( es: *mut pg_sys::ExplainState, label: &str, @@ -1510,7 +1688,7 @@ unsafe fn explain_indent_text(es: *mut pg_sys::ExplainState) { #[cfg(test)] mod tests { - use super::{format_bytes_human, ProfileCollectionMode}; + use super::{format_bytes_human, ColdReadProfile, ProfileCollectionMode}; #[test] fn format_bytes_human_uses_readable_units() { @@ -1535,4 +1713,15 @@ mod tests { ProfileCollectionMode::CountsAndTiming ); } + + #[test] + fn disabled_cold_profile_owns_no_diagnostic_heap_buffers() { + let profile = ColdReadProfile::disabled(); + + assert_eq!(profile.manifest_path.capacity(), 0); + assert_eq!(profile.storage_type.capacity(), 0); + assert_eq!(profile.base_path.capacity(), 0); + assert_eq!(profile.projected_columns.capacity(), 0); + assert_eq!(profile.segments.capacity(), 0); + } } diff --git a/crates/pg_koldstore/src/merge_scan/pg/tuple.rs b/crates/pg_koldstore/src/merge_scan/pg/tuple.rs index 91df0e27..06b0bbaf 100644 --- a/crates/pg_koldstore/src/merge_scan/pg/tuple.rs +++ b/crates/pg_koldstore/src/merge_scan/pg/tuple.rs @@ -1,7 +1,13 @@ //! Tuple slot and scan-owned Datum helpers for KoldMergeScan. //! //! Materialized rows live in a dedicated AllocSet created at BeginCustomScan. -//! EndCustomScan drops that context, releasing all pass-by-ref Datums at once. +//! EndCustomScan clears scan/result slots, then drops that context so Datums are +//! released only after PostgreSQL no longer aliases them. +//! +//! On portal ERROR, PostgreSQL skips `ExecutorEnd` / `EndCustomScan` and deletes +//! the portal memory tree (including this AllocSet). Abort scrub then +//! [`ScanMemory::disown`]s so Rust Drop does not call `MemoryContextDelete` a +//! second time (glibc double-free / backend Abort). use pgrx::memcxt::PgMemoryContexts; use pgrx::pg_sys; @@ -16,29 +22,50 @@ pub(super) struct MaterializedRow { /// Scan-local AllocSet that owns all materialized Datums for one CustomScan node. #[derive(Debug)] pub(super) struct ScanMemory { - context: PgMemoryContexts, + context: Option, } impl ScanMemory { /// Creates a child AllocSet under `CurrentMemoryContext`. pub(super) fn create(name: &str) -> Self { Self { - context: PgMemoryContexts::new(name), + context: Some(PgMemoryContexts::new(name)), } } /// Runs `f` with allocations going into this scan context. pub(super) unsafe fn switch(&mut self, f: impl FnOnce() -> T) -> T { - self.context.switch_to(|_| f()) + let context = self + .context + .as_mut() + .expect("ScanMemory::switch after disown"); + context.switch_to(|_| f()) } /// Releases Datums from the previously emitted streamed row. /// /// # Safety /// - /// Callers must not retain Datums allocated in this context across reset. + /// Callers must clear any TupleTableSlot that still aliases Datums from this + /// context before reset, and must not retain those Datums afterward. pub(super) unsafe fn reset(&mut self) { - self.context.reset(); + let context = self + .context + .as_mut() + .expect("ScanMemory::reset after disown"); + context.reset(); + } + + /// Relinquish ownership when PostgreSQL already deleted the AllocSet. + /// + /// Portal abort deletes query memory children before Rust can run + /// `EndCustomScan`. Forgetting the wrapper avoids a second + /// `MemoryContextDelete` when the stale [`super::ScanExecutionState`] is + /// later drained. + pub(super) fn disown(&mut self) { + if let Some(context) = self.context.take() { + std::mem::forget(context); + } } } @@ -72,7 +99,10 @@ pub(super) unsafe fn store_materialized_row( pg_sys::ExecStoreVirtualTuple(slot); } -unsafe fn clear_slot(slot: *mut pg_sys::TupleTableSlot) { +pub(super) unsafe fn clear_slot(slot: *mut pg_sys::TupleTableSlot) { + if slot.is_null() { + return; + } if !(*slot).tts_ops.is_null() { if let Some(clear) = (*(*slot).tts_ops).clear { clear(slot); @@ -80,8 +110,20 @@ unsafe fn clear_slot(slot: *mut pg_sys::TupleTableSlot) { } } +/// Clears CustomScan slots that may still alias scan-owned Datums. +/// +/// Must run before dropping [`ScanMemory`] so `ExecClearTuple` never frees +/// pointers that already vanished with the AllocSet. +pub(super) unsafe fn clear_custom_scan_slots(node: *mut pg_sys::CustomScanState) { + if node.is_null() { + return; + } + clear_slot((*node).ss.ss_ScanTupleSlot); + clear_slot((*node).ss.ps.ps_ResultTupleSlot); +} + pub(super) unsafe fn slot_attribute_count(slot: *mut pg_sys::TupleTableSlot) -> Option { - if (*slot).tts_tupleDescriptor.is_null() { + if slot.is_null() || (*slot).tts_tupleDescriptor.is_null() { return None; } usize::try_from((*(*slot).tts_tupleDescriptor).natts).ok() diff --git a/crates/pg_koldstore/src/mirror/apply.rs b/crates/pg_koldstore/src/mirror/apply.rs index a05a34b1..f48e37d3 100644 --- a/crates/pg_koldstore/src/mirror/apply.rs +++ b/crates/pg_koldstore/src/mirror/apply.rs @@ -1,31 +1,37 @@ //! Bounded, set-based application of committed `pgoutput` changes. //! //! Ordering (idempotent under crash): -//! 1. Peek available WAL (`pg_logical_slot_peek_binary_changes`) -//! 2. Write latest-state mirror rows (Insert/Delete: PK `ON CONFLICT` upsert; +//! 1. Advance the slot to any **previously committed** durable `applied_lsn` +//! (frees recyclable WAL even on flush fences that skip recording) +//! 2. Peek available WAL (`pg_logical_slot_peek_binary_changes`) +//! 3. Write latest-state mirror rows (Insert/Delete: PK `ON CONFLICT` upsert; //! Update: keyed update) -//! 3. Record durable `applied_lsn` as the exact last decoded source commit +//! 4. Record durable `applied_lsn` as the exact last decoded source commit //! end-LSN in `koldstore.async_mirror_state` (never the global insert LSN) -//! 4. On the **next** call, advance the slot to that LSN +//! 5. On the **next** apply transaction (worker empty follow-up / next tick), +//! advance the slot to that LSN — never in the same uncommitted txn as +//! step 4 (`pg_replication_slot_advance` is not rolled back with SPI) //! -//! A crash between steps 2 and 4 may re-peek already-applied changes; replay is +//! A crash between steps 4 and 5 may re-peek already-applied changes; replay is //! safe because mirror writes are latest-state upserts. Batches are capped at //! [`koldstore_wal_mirror::APPLY_BATCH_ROWS`] and cleared on every flush. //! //! Flush prune fences use [`apply_bounded`] with an explicit `upto_lsn`, //! transaction skip boundary, and `acknowledge_durable_checkpoint = false` so -//! the still-uncommitted flush transaction cannot advance the slot. +//! the still-uncommitted flush transaction cannot **record** a new applied_lsn +//! or empty-advance past its fence. Prior committed applied WAL is still +//! acknowledged in step 1 so flush finalize cannot pin recyclable WAL. use std::collections::{HashMap, HashSet}; use koldstore_catalog::{async_managed_relation, queries::plan_async_managed_relation_by_oid}; use koldstore_common::{format_pg_lsn, next_id_after, MirrorOperation}; use koldstore_wal_mirror::{ - budget_hit, decode_message, must_flush_before_push, parse_pk_bool, parse_pk_ints, - pg_value_text, pk_identity, plan_async_mirror_batch_delete_existing, - plan_async_mirror_batch_update, plan_async_mirror_batch_upsert, primary_key_json, - resolve_row_budget, resolve_time_budget, PgOutputMessage, PgOutputRelation, PgOutputTuple, - APPLY_BATCH_ROWS, + budget_hit, decode_message, must_flush_before_push, pk_column_indexes, pk_identity, + pk_type_oids, plan_async_mirror_batch_delete_existing, plan_async_mirror_batch_update, + plan_async_mirror_batch_upsert, resolve_row_budget, resolve_time_budget, + take_pk_cells_and_order_text, PgOutputMessage, PgOutputRelation, PgOutputTuple, PkBindColumn, + PkCell, PkIdentity, APPLY_BATCH_ROWS, }; use pgrx::datum::DatumWithOid; use serde_json::Value; @@ -57,6 +63,10 @@ struct ManagedRelation { order_column: Option, /// Cached `format_type` spellings for each primary-key column. pk_type_names: Option>, + /// Cached relation-tuple indexes for managed PK columns. + pk_indexes: Option>, + /// Cached PostgreSQL type OIDs for managed PK columns. + pk_type_oids: Option>, /// Cached upsert SQL for typed `unnest` binds (Insert when no order key, /// or Insert/Update with order key). upsert_sql: Option, @@ -69,6 +79,8 @@ struct ManagedRelation { impl ManagedRelation { fn invalidate_plans(&mut self) { self.pk_type_names = None; + self.pk_indexes = None; + self.pk_type_oids = None; self.upsert_sql = None; self.update_sql = None; self.delete_sql = None; @@ -88,25 +100,62 @@ struct BatchKey { #[derive(Debug)] struct ApplyBatch { key: BatchKey, - rows: Vec, - seen: HashSet, + pk_columns: Vec, + seqs: Vec, + order_keys: Option>>, + seen: HashSet, } impl ApplyBatch { - fn new(key: BatchKey) -> Self { + fn new(key: BatchKey, type_oids: &[u32], include_order_key: bool) -> Self { Self { key, - rows: Vec::with_capacity(APPLY_BATCH_ROWS), + pk_columns: type_oids + .iter() + .map(|oid| PkBindColumn::with_capacity(*oid, APPLY_BATCH_ROWS)) + .collect(), + seqs: Vec::with_capacity(APPLY_BATCH_ROWS), + order_keys: include_order_key.then(|| Vec::with_capacity(APPLY_BATCH_ROWS)), seen: HashSet::with_capacity(APPLY_BATCH_ROWS), } } + + fn len(&self) -> usize { + self.seqs.len() + } + + fn push( + &mut self, + pk_cells: Vec, + seq: i64, + order_key: Option>, + ) -> Result<(), String> { + if pk_cells.len() != self.pk_columns.len() { + return Err(format!( + "async mirror PK width {} does not match batch width {}", + pk_cells.len(), + self.pk_columns.len() + )); + } + for (column, cell) in self.pk_columns.iter_mut().zip(pk_cells) { + column.push_cell(cell, "pk")?; + } + self.seqs.push(seq); + if let Some(order_keys) = self.order_keys.as_mut() { + order_keys.push( + order_key.ok_or_else(|| "async mirror batch row missing order_key".to_string())?, + ); + } + Ok(()) + } } /// Applies committed WAL under an explicit fence request. /// /// Acquires the database slot lock for the current transaction, then applies. -/// Flush finalize should prefer [`try_lock_slot`] + [`apply_bounded_locked`] so -/// encode/upload never wait on a blocked slot lock. +/// The WAL applier uses [`super::lifecycle::try_lock_slot`] + +/// [`apply_bounded_locked`] so a waiting flush finalize is not starved. +/// Encode/upload never hold this lock, and heap DML does not take it. /// /// Scheduling is deliberately not coupled to synchronous fence calls. Durable /// WAL and supervisor generations own background progress; this function only @@ -151,10 +200,11 @@ pub fn apply_bounded_locked(request: BoundedApplyRequest) -> Result Result { flush_batch(&mut batch, &relations, &mut managed, &mut type_names)?; transaction_lsn = Some(final_lsn); @@ -258,7 +308,7 @@ pub fn apply_bounded_locked(request: BoundedApplyRequest) -> Result Result Result Result<(), String> { Ok(()) } -fn fetch_decode_messages(cursor_name: &str) -> Result>, String> { - pgrx::Spi::connect_mut(|client| -> Result>, String> { +fn fetch_decode_messages(cursor_name: &str) -> Result, String> { + pgrx::Spi::connect_mut(|client| -> Result, String> { let mut cursor = client .find_cursor(cursor_name) .map_err(|error| error.to_string())?; let tuples = cursor .fetch(DECODE_FETCH_ROWS) .map_err(|error| error.to_string())?; - let messages = tuples - .into_iter() - .map(|row| { - row.get_by_name::, &str>("data") - .map_err(|error| format!("read decoded cursor row: {error}"))? - .ok_or_else(|| "logical decoding returned NULL data".to_string()) - }) - .collect::, String>>()?; + let mut messages = Vec::new(); + for row in tuples { + let data = row + .get_by_name::, &str>("data") + .map_err(|error| format!("read decoded cursor row: {error}"))? + .ok_or_else(|| "logical decoding returned NULL data".to_string())?; + messages.push(decode_message(&data).map_err(|error| error.to_string())?); + } if messages.is_empty() { drop(cursor); } else { @@ -594,7 +644,7 @@ fn push_change( type_names: &mut HashMap<(u32, i32), String>, relation_id: u32, operation: MirrorOperation, - tuple: &PgOutputTuple, + mut tuple: PgOutputTuple, transaction_lsn: Option, request: &BoundedApplyRequest, seq_watermark: &mut i64, @@ -605,14 +655,68 @@ fn push_change( let relation = relations .get(&relation_id) .ok_or_else(|| format!("pgoutput row references unknown relation {relation_id}"))?; - let config = managed_relation(managed, relation_id)?; - let Some(config) = config else { - return Ok(()); - }; - let mut row = primary_key_json(relation, &config.primary_key, tuple)?; - if operation != MirrorOperation::Delete { - if let Some(order) = config.order_column.as_ref() { - let text = order_column_text(relation, order, tuple)?; + let (pk_cells, include_order_key, table_oid, type_oids, order_key, identity, key) = { + let config = managed_relation(managed, relation_id)?; + let Some(config) = config else { + return Ok(()); + }; + ensure_pk_layout(config, relation)?; + let key_columns = config.pk_indexes.as_ref().expect("pk indexes populated"); + let pk_type_oids = config + .pk_type_oids + .as_ref() + .expect("pk type oids populated"); + let include_order_key = config.include_order_key() && operation != MirrorOperation::Delete; + let order_column_name = if include_order_key { + Some( + config + .order_column + .as_ref() + .ok_or_else(|| { + "async mirror order column missing for order-key batch".to_string() + })? + .name + .as_str(), + ) + } else { + None + }; + let (pk_cells, order_text) = take_pk_cells_and_order_text( + relation, + &config.primary_key, + key_columns, + pk_type_oids, + order_column_name, + &mut tuple, + )?; + let table_oid = config.table_oid.to_u32(); + let identity = pk_identity(&pk_cells); + let key = BatchKey { + relation_id, + operation, + }; + let needs_new_batch = match batch.as_ref() { + None => true, + Some(current) => must_flush_before_push( + Some(¤t.key), + &key, + current.len(), + ¤t.seen, + &identity, + APPLY_BATCH_ROWS, + ) + .is_some(), + }; + // Clone type OIDs only when opening a batch, not on every unique-key row. + let type_oids = needs_new_batch.then(|| pk_type_oids.clone()); + let mut order_key = None; + if include_order_key { + let order = config.order_column.as_ref().ok_or_else(|| { + "async mirror order column missing for order-key batch".to_string() + })?; + let text = order_text.ok_or_else(|| { + format!("async mirror order text missing for column {}", order.name) + })?; let ty = koldstore_sortkey::SortKeyType::from_type_oid(order.type_oid).ok_or_else(|| { format!( @@ -622,46 +726,39 @@ fn push_change( })?; let encoded = koldstore_sortkey::encode_sort_key_pg_text(ty, &text) .map_err(|error| error.to_string())?; - row.insert( - "order_key".to_string(), - Value::Array(encoded.into_iter().map(Value::from).collect()), - ); + order_key = Some(encoded); } - } + ( + pk_cells, + include_order_key, + table_oid, + type_oids, + order_key, + identity, + key, + ) + }; // Allocate above the durable high-watermark (and prune floor when fencing). let mut floor = *seq_watermark; if let Some((target_oid, prune_floor)) = request.target_prune_floor { - if config.table_oid.to_u32() == target_oid { + if table_oid == target_oid { floor = floor.max(prune_floor.get()); } } let seq = next_id_after(crate::sql::session::snowflake_worker_id(), floor) .map_err(|error| error.to_string())?; *seq_watermark = seq; - row.insert("seq".to_string(), Value::from(seq)); - let identity = pk_identity(&row); - let key = BatchKey { - relation_id, - operation, - }; - let needs_flush = match batch.as_ref() { - Some(current) => must_flush_before_push( - Some(¤t.key), - &key, - current.rows.len(), - ¤t.seen, - &identity, - APPLY_BATCH_ROWS, - ) - .is_some(), - None => false, - }; - if needs_flush { + if type_oids.is_some() && batch.is_some() { flush_batch(batch, relations, managed, type_names)?; } - let current = batch.get_or_insert_with(|| ApplyBatch::new(key)); + if let Some(type_oids) = type_oids { + *batch = Some(ApplyBatch::new(key, &type_oids, include_order_key)); + } + let current = batch + .as_mut() + .expect("apply batch exists after optional open"); current.seen.insert(identity); - current.rows.push(Value::Object(row)); + current.push(pk_cells, seq, order_key)?; Ok(()) } @@ -674,7 +771,7 @@ fn flush_batch( let Some(batch) = batch.take() else { return Ok(()); }; - if batch.rows.is_empty() { + if batch.seqs.is_empty() { return Ok(()); } let relation = relations @@ -684,13 +781,7 @@ fn flush_batch( .get_mut(&batch.key.relation_id) .and_then(Option::as_mut) .ok_or_else(|| "managed relation disappeared while applying batch".to_string())?; - apply_batch( - config, - relation, - type_names, - batch.key.operation, - &batch.rows, - )?; + apply_batch(config, relation, type_names, batch)?; // After SPI mirror writes succeed but before applied_lsn is recorded. crate::failpoints::hit(ASYNC_MIRROR_APPLY_AFTER_BATCH_FAILPOINT)?; Ok(()) @@ -699,7 +790,7 @@ fn flush_batch( fn managed_relation( cache: &mut HashMap>, relation_id: u32, -) -> Result, String> { +) -> Result, String> { if let std::collections::hash_map::Entry::Vacant(entry) = cache.entry(relation_id) { let statement = plan_async_managed_relation_by_oid().map_err(|error| error.to_string())?; let json = crate::spi::select_one::( @@ -710,7 +801,7 @@ fn managed_relation( let parsed = json.map(|json| parse_managed_relation(&json)).transpose()?; entry.insert(parsed); } - Ok(cache.get(&relation_id).and_then(Option::as_ref)) + Ok(cache.get_mut(&relation_id).and_then(Option::as_mut)) } fn parse_managed_relation(json: &str) -> Result { @@ -725,32 +816,26 @@ fn parse_managed_relation(json: &str) -> Result { type_oid: order.type_oid, }), pk_type_names: None, + pk_indexes: None, + pk_type_oids: None, upsert_sql: None, update_sql: None, delete_sql: None, }) } -fn order_column_text( +fn ensure_pk_layout( + config: &mut ManagedRelation, relation: &PgOutputRelation, - order: &OrderColumnConfig, - tuple: &PgOutputTuple, -) -> Result { - let relation_index = relation - .columns - .iter() - .position(|column| column.name == order.name) - .ok_or_else(|| { - format!( - "pgoutput relation {}.{} does not publish segment order column {}", - relation.namespace, relation.name, order.name - ) - })?; - let value = tuple - .values - .get(relation_index) - .ok_or_else(|| format!("tuple omits segment order column {}", order.name))?; - pg_value_text(value, &order.name, "segment order") +) -> Result<(), String> { + if config.pk_indexes.is_some() { + return Ok(()); + } + let indexes = pk_column_indexes(relation, &config.primary_key)?; + let oids = pk_type_oids(relation, &indexes)?; + config.pk_indexes = Some(indexes); + config.pk_type_oids = Some(oids); + Ok(()) } fn ensure_pk_type_names( @@ -792,8 +877,7 @@ fn apply_batch( config: &mut ManagedRelation, relation: &PgOutputRelation, type_names: &mut HashMap<(u32, i32), String>, - operation: MirrorOperation, - rows: &[Value], + batch: ApplyBatch, ) -> Result<(), String> { ensure_pk_type_names(config, relation, type_names)?; let pk_refs = config @@ -803,6 +887,7 @@ fn apply_batch( .collect::>(); let pk_types = config.pk_type_names.as_ref().expect("pk types populated"); let include_order_key = config.include_order_key(); + let operation = batch.key.operation; let sql = match operation { MirrorOperation::Update => { if config.update_sql.is_none() { @@ -843,56 +928,21 @@ fn apply_batch( } }; - let mut pk_columns: Vec> = (0..config.primary_key.len()) - .map(|_| Vec::with_capacity(rows.len())) - .collect(); - let mut seqs = Vec::with_capacity(rows.len()); - let need_order_keys = include_order_key && operation != MirrorOperation::Delete; - let mut order_keys = need_order_keys.then(|| Vec::>::with_capacity(rows.len())); - for row in rows { - let object = row - .as_object() - .ok_or_else(|| "async mirror batch row is not an object".to_string())?; - for (index, key) in config.primary_key.iter().enumerate() { - let cell = object - .get(key) - .ok_or_else(|| format!("async mirror batch row missing primary key {key}"))?; - let text = match cell { - Value::String(text) => text.clone(), - other => other.to_string(), - }; - pk_columns[index].push(text); - } - let seq = object - .get("seq") - .and_then(Value::as_i64) - .ok_or_else(|| "async mirror batch row missing seq".to_string())?; - seqs.push(seq); - if let Some(order_keys) = order_keys.as_mut() { - let encoded = object - .get("order_key") - .and_then(Value::as_array) - .ok_or_else(|| "async mirror batch row missing order_key".to_string())? - .iter() - .map(|cell| { - cell.as_u64() - .and_then(|n| u8::try_from(n).ok()) - .ok_or_else(|| "order_key byte is not u8".to_string()) - }) - .collect::, _>>()?; - order_keys.push(encoded); - } - } - + let ApplyBatch { + pk_columns, + seqs, + order_keys, + .. + } = batch; let result = pgrx::Spi::connect(|client| -> Result<(i64, i64), String> { let mut args: Vec> = Vec::with_capacity(pk_columns.len() + 3); args.push(DatumWithOid::from(operation.code())); - for (index, column) in pk_columns.iter().enumerate() { - push_typed_pk_array_arg(&mut args, &pk_types[index], column)?; + for column in pk_columns { + push_typed_pk_array_arg(&mut args, column); } - args.push(DatumWithOid::from(seqs.clone())); - if let Some(order_keys) = order_keys.as_ref() { - args.push(DatumWithOid::from(order_keys.clone())); + args.push(DatumWithOid::from(seqs)); + if let Some(order_keys) = order_keys { + args.push(DatumWithOid::from(order_keys)); } let table = client .select(sql, None, &args) @@ -931,51 +981,24 @@ fn apply_batch( Ok(()) } -/// Binds one primary-key column as a typed array when the SQL planner emitted -/// `{type}[]`, otherwise as `text[]` for the cast fallback path. -fn push_typed_pk_array_arg( - args: &mut Vec>, - type_name: &str, - cells: &[String], -) -> Result<(), String> { - let normalized = type_name.trim().to_ascii_lowercase(); - match normalized.as_str() { - "bigint" | "int8" => { - let values = parse_pk_ints::(cells, type_name)?; - args.push(DatumWithOid::from(values)); - } - "integer" | "int4" | "int" => { - let values = parse_pk_ints::(cells, type_name)?; - args.push(DatumWithOid::from(values)); - } - "smallint" | "int2" => { - let values = parse_pk_ints::(cells, type_name)?; - args.push(DatumWithOid::from(values)); - } - "boolean" | "bool" => { - let values = cells - .iter() - .map(|cell| parse_pk_bool(cell)) - .collect::, _>>()?; - args.push(DatumWithOid::from(values)); - } - "text" | "varchar" | "character varying" | "name" => { - args.push(DatumWithOid::from(cells.to_vec())); - } - _ => { - // uuid / float / domains / uncommon scalars: SQL casts from text[]. - args.push(DatumWithOid::from(cells.to_vec())); - } +/// Binds one already-parsed primary-key column as a native SPI array. +fn push_typed_pk_array_arg(args: &mut Vec>, column: PkBindColumn) { + match column { + PkBindColumn::Int8(values) => args.push(DatumWithOid::from(values)), + PkBindColumn::Int4(values) => args.push(DatumWithOid::from(values)), + PkBindColumn::Int2(values) => args.push(DatumWithOid::from(values)), + PkBindColumn::Bool(values) => args.push(DatumWithOid::from(values)), + PkBindColumn::Text(values) => args.push(DatumWithOid::from(values)), } - Ok(()) } /// Applies committed WAL available at the fence boundary and returns row changes. /// -/// SQL contract: `koldstore.wait_for_async_mirror()` is an **optional** strong- -/// consistency fence for callers that need the mirror caught up before a read -/// or benchmark sample. It is **not** on the flush hot path: queue-mode -/// `flush_table` and auto-flush enqueue durable work and return. +/// SQL contract: `koldstore.wait_for_async_mirror()` is an optional committed- +/// change fence for callers that need the mirror caught up before a read or +/// benchmark sample. It cannot observe the caller's uncommitted changes or +/// advance a snapshot acquired before the call. It is **not** on the flush hot +/// path: queue-mode `flush_table` and auto-flush enqueue durable work and return. /// /// Captures a durable WAL upper bound at call time and applies through that /// bound only. Concurrent commits after the fence LSN are not waited on — diff --git a/crates/pg_koldstore/src/mirror/lifecycle.rs b/crates/pg_koldstore/src/mirror/lifecycle.rs index 246c7249..cef3ca49 100644 --- a/crates/pg_koldstore/src/mirror/lifecycle.rs +++ b/crates/pg_koldstore/src/mirror/lifecycle.rs @@ -276,12 +276,13 @@ fn validate_slot(slot: &str) -> Result<(), String> { /// /// Transaction-scoped advisory lock. Flush must not hold this during Parquet /// encode/upload — only during claim-time watermark reads (optional) and the -/// finalize fence. Prefer [`try_lock_slot`] from flush finalize paths. +/// finalize fence. Flush finalize and the WAL applier both [`try_lock_slot`] so +/// a waiting finalize is not starved by the next apply tick. pub(crate) fn lock_slot(database_oid: u32) -> Result<(), String> { lock_database(APPLY_LOCK_NAMESPACE, database_oid) } -/// Non-blocking variant of [`lock_slot`] for fail-fast flush finalize. +/// Non-blocking variant of [`lock_slot`]. /// /// Returns `true` when this transaction now holds the lock (including when the /// same backend already held it). Returns `false` when another backend holds it. @@ -293,6 +294,9 @@ pub(crate) fn try_lock_slot(database_oid: u32) -> Result { try_lock_database(APPLY_LOCK_NAMESPACE, database_oid) } +/// How long flush finalize polls [`try_lock_slot`] before failing the job. +pub(crate) const SLOT_LOCK_WAIT: Duration = Duration::from_secs(10); + /// Poll interval while waiting for another backend to release the logical slot. const SLOT_INACTIVE_POLL: Duration = Duration::from_millis(10); /// Abort-window wait only: locks are released before `ReplicationSlotRelease`. @@ -378,13 +382,16 @@ impl Drop for WalServiceDisableGuard { } } -/// Stops the current database WAL applier so disable cannot deadlock on [`lock_slot`]. +/// Stops the current database WAL applier so callers can take [`lock_slot`] safely. /// /// The applier may hold the apply lock inside a peek that waits for concurrent /// XIDs — including this backend's open transaction (common under `#[pg_test]`). /// Terminate by `backend_type` (not only `active_pid`) so a worker blocked /// before acquiring the slot is also cleared. -fn stop_async_mirror_applier(database_oid: u32, slot: &str) -> Result<(), String> { +/// +/// Used by disable/teardown and by `#[pg_test]` fixtures that manage a populated +/// table in the same uncommitted transaction that wrote the seed rows. +pub(crate) fn stop_async_mirror_applier(database_oid: u32, slot: &str) -> Result<(), String> { let worker_type = koldstore_wal_mirror::wal_applier_worker_type(database_oid); // Always return a row: an empty SELECT through Spi::run_with_args errors with // "SpiTupleTable positioned before the start or after the end" when no WAL diff --git a/crates/pg_koldstore/src/pg_tests/fixture.rs b/crates/pg_koldstore/src/pg_tests/fixture.rs index d022571b..bc174f3f 100644 --- a/crates/pg_koldstore/src/pg_tests/fixture.rs +++ b/crates/pg_koldstore/src/pg_tests/fixture.rs @@ -52,6 +52,21 @@ pub(crate) fn register_temp_storage(label: &str) -> String { name } +/// Stops the WAL applier and holds the apply lock for the rest of this `#[pg_test]`. +/// +/// Populated manage takes the apply lock for publish/backfill/catch-up. If the +/// persistent applier already holds that lock while waiting on this backend's +/// open XID (seed INSERT), the deadlock detector cannot see the cycle and the +/// test stalls for minutes. Call this after seeding and before manage/ALTER. +pub(crate) fn hold_apply_lock_for_populated_manage() { + let database_oid = unsafe { pgrx::pg_sys::MyDatabaseId }.to_u32(); + let slot = crate::mirror::lifecycle::slot_name(database_oid); + crate::mirror::lifecycle::stop_async_mirror_applier(database_oid, &slot) + .expect("stop WAL applier before populated manage"); + crate::mirror::lifecycle::lock_slot(database_oid) + .expect("hold apply lock for populated manage"); +} + /// Creates a simple heap table with a bigint primary key and text body. pub(crate) fn create_messages_table(schema: &str, table: &str) { Spi::run(&format!("CREATE SCHEMA IF NOT EXISTS {schema}")).expect("create schema"); @@ -61,6 +76,17 @@ pub(crate) fn create_messages_table(schema: &str, table: &str) { .expect("create messages table"); } +/// Returns the generated mirror relation for a source relation. +pub(crate) fn change_log_mirror_relation(source_relation: &str) -> String { + let source = koldstore_common::TableName::parse(source_relation) + .expect("pg_test source relation must be a safe identifier"); + koldstore_wal_mirror::mirror_relation_for_source(&source) + .expect("pg_test source relation must produce a mirror") + .table_name() + .as_str() + .to_string() +} + /// Manages a shared table with flush-friendly settings for small fixtures. /// /// Includes `migration_order_by => 'id'` so seeded (pre-manage) heaps can backfill diff --git a/crates/pg_koldstore/src/pg_tests/manage.inc.rs b/crates/pg_koldstore/src/pg_tests/manage.inc.rs index 2920215a..2d4e8362 100644 --- a/crates/pg_koldstore/src/pg_tests/manage.inc.rs +++ b/crates/pg_koldstore/src/pg_tests/manage.inc.rs @@ -4,7 +4,7 @@ fn manage_populated_table_is_independent_of_caller_search_path() { let schema = format!("pgtest_{suffix}"); let table = format!("messages_{suffix}"); let relation = format!("{schema}.{table}"); - let mirror = format!("koldstore.{table}__cl"); + let mirror = change_log_mirror_relation(&relation); let storage = register_temp_storage(&suffix); create_messages_table(&schema, &table); Spi::run(&format!( @@ -34,6 +34,167 @@ fn manage_populated_table_is_independent_of_caller_search_path() { ); } +#[pg_test] +fn manage_same_named_tables_in_distinct_schemas_uses_distinct_mirrors() { + let suffix = unique_suffix("mirror_schema_collision"); + let first_schema = format!("first_{suffix}"); + let second_schema = format!("second_{suffix}"); + let first_relation = format!("{first_schema}.messages"); + let second_relation = format!("{second_schema}.messages"); + let first_mirror = format!("koldstore.{first_schema}_messages__cl"); + let second_mirror = format!("koldstore.{second_schema}_messages__cl"); + let storage = register_temp_storage(&suffix); + + create_messages_table(&first_schema, "messages"); + create_messages_table(&second_schema, "messages"); + manage_shared(&first_relation, &storage); + manage_shared(&second_relation, &storage); + + assert_ne!(first_mirror, second_mirror); + assert_eq!(spi_get_i64(&format!("SELECT count(*) FROM {first_mirror}")), 0); + assert_eq!(spi_get_i64(&format!("SELECT count(*) FROM {second_mirror}")), 0); + + Spi::run(&format!( + "SELECT koldstore.unmanage_table('{first_relation}'::regclass)" + )) + .expect("unmanage first relation"); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{second_mirror}') IS NOT NULL)::int" + )), + 1, + "unmanaging one source must not drop another source's mirror" + ); +} + +#[pg_test] +fn unmanage_refuses_a_legacy_shared_mirror() { + let suffix = unique_suffix("legacy_shared_mirror"); + let first_schema = format!("first_{suffix}"); + let second_schema = format!("second_{suffix}"); + let first_relation = format!("{first_schema}.messages"); + let second_relation = format!("{second_schema}.messages"); + let first_mirror = format!("koldstore.{first_schema}_messages__cl"); + let storage = register_temp_storage(&suffix); + + create_messages_table(&first_schema, "messages"); + create_messages_table(&second_schema, "messages"); + manage_shared(&first_relation, &storage); + manage_shared(&second_relation, &storage); + Spi::run(&format!( + "UPDATE koldstore.schemas \ + SET mirror_relation = '{first_mirror}'::regclass \ + WHERE table_oid = '{second_relation}'::regclass" + )) + .expect("simulate a legacy shared mirror catalog row"); + + Spi::run(&format!( + r#" + DO $$ + BEGIN + BEGIN + PERFORM koldstore.unmanage_table('{first_relation}'::regclass); + RAISE EXCEPTION 'unmanage unexpectedly accepted a shared mirror'; + EXCEPTION WHEN OTHERS THEN + IF SQLERRM = 'unmanage unexpectedly accepted a shared mirror' THEN + RAISE; + END IF; + END; + END + $$; + "# + )) + .expect("unmanage must fail closed when another active table owns the same mirror"); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{first_mirror}') IS NOT NULL)::int" + )), + 1, + "a failed unmanage must leave the shared mirror intact" + ); +} + +#[pg_test] +fn managed_mirror_follows_source_table_and_schema_renames() { + let suffix = unique_suffix("mirror_rename"); + let original_schema = format!("original_{suffix}"); + let moved_schema = format!("moved_{suffix}"); + let renamed_schema = format!("renamed_{suffix}"); + let original_relation = format!("{original_schema}.messages"); + let moved_relation = format!("{moved_schema}.events"); + let renamed_relation = format!("{renamed_schema}.events"); + let original_mirror = format!("koldstore.{original_schema}_messages__cl"); + let table_renamed_mirror = format!("koldstore.{original_schema}_events__cl"); + let moved_mirror = format!("koldstore.{moved_schema}_events__cl"); + let schema_renamed_mirror = format!("koldstore.{renamed_schema}_events__cl"); + let storage = register_temp_storage(&suffix); + + create_messages_table(&original_schema, "messages"); + manage_shared(&original_relation, &storage); + Spi::run(&format!("ALTER TABLE {original_relation} RENAME TO events")) + .expect("rename managed table"); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{table_renamed_mirror}') IS NOT NULL)::int" + )), + 1, + "table rename must rename its mirror" + ); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{original_mirror}') IS NULL)::int" + )), + 1, + "old generated mirror name must be released" + ); + + Spi::run(&format!("CREATE SCHEMA {moved_schema}")).expect("create target schema"); + Spi::run(&format!("ALTER TABLE {original_schema}.events SET SCHEMA {moved_schema}")) + .expect("move managed table to another schema"); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{moved_mirror}') IS NOT NULL)::int" + )), + 1, + "moving a table to another schema must rename its mirror" + ); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{table_renamed_mirror}') IS NULL)::int" + )), + 1, + "moving a table must release the prior schema-qualified mirror name" + ); + assert_eq!( + spi_get_i64(&format!("SELECT count(*) FROM {moved_relation}")), + 0 + ); + + Spi::run(&format!("ALTER SCHEMA {moved_schema} RENAME TO {renamed_schema}")) + .expect("rename schema containing managed table"); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('{schema_renamed_mirror}') IS NOT NULL)::int" + )), + 1, + "schema rename must rename its managed mirrors" + ); + + create_messages_table(&renamed_schema, "messages"); + manage_shared(&format!("{renamed_schema}.messages"), &storage); + assert_eq!( + spi_get_i64(&format!( + "SELECT (to_regclass('koldstore.{renamed_schema}_messages__cl') IS NOT NULL)::int" + )), + 1, + "a new table may reuse the source's former name" + ); + assert_eq!( + spi_get_i64(&format!("SELECT count(*) FROM {renamed_relation}")), + 0 + ); +} + #[pg_test] fn alter_table_manages_and_replaces_flush_policy() { // register_temp_storage pre-provisions the async slot. ALTER TABLE now also @@ -85,6 +246,9 @@ fn alter_table_manages_a_populated_table() { "INSERT INTO {relation} (body) VALUES ('alpha'), ('beta')" )) .expect("seed populated table"); + // Seed INSERT assigned an XID; keep the WAL applier off the apply lock for + // the rest of this uncommitted #[pg_test] transaction. + hold_apply_lock_for_populated_manage(); Spi::run(&format!( r#" diff --git a/crates/pg_koldstore/src/pg_tests/mirror_dml.inc.rs b/crates/pg_koldstore/src/pg_tests/mirror_dml.inc.rs index 8cb75438..4d5109c9 100644 --- a/crates/pg_koldstore/src/pg_tests/mirror_dml.inc.rs +++ b/crates/pg_koldstore/src/pg_tests/mirror_dml.inc.rs @@ -4,8 +4,8 @@ // `tests/e2e/dml/change_log_mirror.rs`. `#[pg_test]` cannot commit mid-body, so // WAL apply never sees fixture DML; those cases are ignored here. -fn mirror_name(table: &str) -> String { - format!("koldstore.{table}__cl") +fn mirror_name(relation: &str) -> String { + change_log_mirror_relation(relation) } fn reported_mirror_rows(relation: &str) -> i64 { @@ -29,7 +29,7 @@ fn mirror_tracks_insert_update_delete_reinsert_and_rollback() { let schema = format!("pgtest_{suffix}"); let table = "messages"; let relation = format!("{schema}.{table}"); - let mirror = mirror_name(table); + let mirror = mirror_name(&relation); let storage = register_temp_storage(&suffix); create_messages_table(&schema, table); @@ -127,7 +127,7 @@ fn mirror_bulk_update_and_delete_keep_latest_state() { let schema = format!("pgtest_{suffix}"); let table = "messages"; let relation = format!("{schema}.{table}"); - let mirror = mirror_name(table); + let mirror = mirror_name(&relation); let storage = register_temp_storage(&suffix); create_messages_table(&schema, table); diff --git a/crates/pg_koldstore/src/pg_tests/mod.rs b/crates/pg_koldstore/src/pg_tests/mod.rs index e7a8304b..340f1bc2 100644 --- a/crates/pg_koldstore/src/pg_tests/mod.rs +++ b/crates/pg_koldstore/src/pg_tests/mod.rs @@ -30,10 +30,11 @@ mod tests { use pgrx::prelude::*; use super::fixture::{ - assert_finishes_under, create_messages_table, flush_table_rows, jsonb_obj, - manage_for_cold_flush, manage_shared, preprovision_async_mirror, register_temp_storage, - setup_cold_typed_join_fixture, spi_get_explain, spi_get_i64, spi_get_text, spi_succeeds, - unique_suffix, COLD_FACT_IDS, COLD_QUERY_BUDGET, + assert_finishes_under, change_log_mirror_relation, create_messages_table, flush_table_rows, + hold_apply_lock_for_populated_manage, jsonb_obj, manage_for_cold_flush, manage_shared, + preprovision_async_mirror, register_temp_storage, setup_cold_typed_join_fixture, + spi_get_explain, spi_get_i64, spi_get_text, spi_succeeds, unique_suffix, COLD_FACT_IDS, + COLD_QUERY_BUDGET, }; include!("lifecycle.inc.rs"); diff --git a/crates/pg_koldstore/src/pg_tests/scan.inc.rs b/crates/pg_koldstore/src/pg_tests/scan.inc.rs index 6dea87d2..6b34374e 100644 --- a/crates/pg_koldstore/src/pg_tests/scan.inc.rs +++ b/crates/pg_koldstore/src/pg_tests/scan.inc.rs @@ -390,7 +390,6 @@ fn explain_analyze_shows_prune_summary_after_flush() { "Bytes Fetched", "Runtime Catalog Source", "Published Manifest Path", - "Cold Segments Query", ] { assert!( plan.contains(needle), @@ -398,7 +397,10 @@ fn explain_analyze_shows_prune_summary_after_flush() { ); } assert!( - !plan.contains("Timing:") && !plan.contains("Cold Read Time"), + !plan.contains("Timing:") + && !plan.contains("Cold Read Time") + && !plan.contains("Cold Segments Query:") + && !plan.contains("Parquet Segments:"), "TIMING OFF must suppress custom phase timing like native PostgreSQL nodes: {plan}" ); } @@ -446,6 +448,9 @@ fn explain_analyze_shows_scan_merge_flow_and_phase_timing() { "Metadata Time", "Hot Scan Time", "Cold Read Time", + "Parquet Open Time", + "Object Store Read Time", + "Parquet Scan Time", "Mirror Scan Time", "Merge Time", "Materialization Time", @@ -480,6 +485,24 @@ fn explain_analyze_shows_scan_merge_flow_and_phase_timing() { !plan.contains("SPI JSON Keyset Scan") && !plan.contains("to_jsonb(proj)"), "Ordered Progressive must not use SPI JSON keyset hot paging: {plan}" ); + assert!( + !plan.contains("Cold Segments Query:") + && !plan.contains("Segment Index Query:") + && !plan.contains("Parquet Segments:") + && !plan.contains("Object:"), + "ordinary EXPLAIN must keep raw SQL and per-file diagnostics compact: {plan}" + ); + + let verbose = spi_get_explain(&format!( + "EXPLAIN (ANALYZE, VERBOSE, COSTS OFF, SUMMARY OFF) \ + SELECT body FROM {relation} ORDER BY id DESC LIMIT 5" + )); + assert!( + verbose.contains("Cold Segments Query:") + && verbose.contains("Parquet Segments:") + && verbose.contains("Object:"), + "EXPLAIN VERBOSE must retain raw catalog SQL and per-file diagnostics: {verbose}" + ); } #[pg_test] @@ -599,8 +622,8 @@ fn explain_analyze_counts_mirror_overlay_rows() { // this same transaction still conflicts in the heap's unique index. Seed // the post-flush mirror state directly to isolate EXPLAIN's overlay metrics. Spi::run(&format!( - "INSERT INTO {mirror} (id, seq, op) \ - SELECT 2, last_flush_seq + 1, 3 \ + "INSERT INTO {mirror} (id, order_key, seq, op) \ + SELECT 2, koldstore.internal_encode_sort_key(2::bigint), last_flush_seq + 1, 3 \ FROM koldstore.schemas \ WHERE table_oid = '{relation}'::regclass AND active" )) @@ -1198,6 +1221,51 @@ fn merge_scan_fails_closed_when_seen_key_limit_is_exceeded() { "# )) .expect("seen-key limit must fail closed with a clear error"); + // Same backend must remain usable: portal ERROR skips EndCustomScan, so + // SCAN_STATES abort scrub must disown ScanMemory instead of double-freeing. + let still_alive = Spi::get_one::("SELECT 1").expect("spi").expect("row"); + assert_eq!(still_alive, 1); + let recount = Spi::get_one::(&format!( + "SELECT count(*) FROM {relation} WHERE id = 1" + )) + .expect("point lookup after seen-key error") + .expect("row"); + assert_eq!(recount, 1); + Spi::run("RESET koldstore.max_merge_seen_keys").expect("reset seen-key limit"); +} + +#[pg_test] +fn merge_scan_survives_followup_query_after_unbounded_count() { + let suffix = unique_suffix("seen_unlimited"); + let schema = format!("pgtest_{suffix}"); + let table = "messages"; + let relation = format!("{schema}.{table}"); + let storage = register_temp_storage(&suffix); + + create_messages_table(&schema, table); + Spi::run(&format!( + "INSERT INTO {relation} (id, body) + SELECT gs, 'body-' || gs::text + FROM generate_series(1, 400) AS gs" + )) + .expect("insert"); + manage_for_cold_flush(&relation, &storage); + let flushed = flush_table_rows(&relation, true); + assert!(flushed >= 300, "expected cold rows, rows_flushed={flushed}"); + + Spi::run("SET koldstore.max_merge_seen_keys = 0").expect("disable seen-key cap"); + let total = Spi::get_one::(&format!("SELECT count(*) FROM {relation}")) + .expect("unbounded count") + .expect("row"); + assert_eq!(total, 400); + // Regression: large merge-scan teardown used to double-free on the next + // statement in the same backend (signal 6 / glibc abort). + let followup = Spi::get_one::(&format!("SELECT id FROM {relation} WHERE id = 42")) + .expect("follow-up point lookup") + .expect("row"); + assert_eq!(followup, 42); + let alive = Spi::get_one::("SELECT 1").expect("spi").expect("row"); + assert_eq!(alive, 1); Spi::run("RESET koldstore.max_merge_seen_keys").expect("reset seen-key limit"); } @@ -1259,6 +1327,58 @@ fn ordered_pk_limit_uses_kold_merge_scan_without_external_sort() { ); } +#[pg_test] +fn migration_order_defaults_to_segment_order_for_ordered_cold_reads() { + let suffix = unique_suffix("migration_order_segment_order"); + let schema = format!("pgtest_{suffix}"); + let relation = format!("{schema}.messages"); + let storage = register_temp_storage(&suffix); + + Spi::run(&format!( + "CREATE SCHEMA {schema}; \ + CREATE TABLE {relation} (\ + id bigint PRIMARY KEY, \ + created_at timestamptz NOT NULL, \ + body text NOT NULL\ + ); \ + CREATE INDEX messages_created_at_idx ON {relation} (created_at DESC); \ + INSERT INTO {relation} (id, created_at, body) VALUES \ + (1, '2026-01-01 00:00:00+00', 'old'), \ + (2, '2026-01-02 00:00:00+00', 'new')" + )) + .expect("create and seed ordered fixture"); + Spi::run(&format!( + "SELECT koldstore.manage_table(\ + table_name => '{relation}'::regclass, \ + storage => '{storage}', \ + hot_row_limit => 1, \ + min_flush_rows => 1, \ + max_rows_per_file => 1000, \ + migration_order_by => 'created_at'\ + )" + )) + .expect("manage with migration order only"); + assert!(flush_table_rows(&relation, true) >= 2); + + assert_eq!( + spi_get_i64(&format!( + "SELECT (options->>'segment_order_column_id')::bigint \ + FROM koldstore.schemas \ + WHERE table_oid = '{relation}'::regclass AND active" + )), + 2, + "migration order must persist as the cold segment order" + ); + let plan = spi_get_explain(&format!( + "EXPLAIN (COSTS OFF) \ + SELECT body FROM {relation} ORDER BY created_at DESC LIMIT 1" + )); + assert!( + plan.contains("Strategy: Ordered Progressive"), + "migration-order default must enable the ordered merge path: {plan}" + ); +} + #[pg_test] fn ordered_limit_does_not_drain_full_hot_heap() { let suffix = unique_suffix("ordered_limit_lazy"); @@ -1608,4 +1728,3 @@ fn unordered_limit_uses_hot_first_and_defers_cold() { "hot-first LIMIT should return hot bodies before opening cold: {bodies}" ); } - diff --git a/crates/pg_koldstore/src/spi.rs b/crates/pg_koldstore/src/spi.rs index b6859141..571705b1 100644 --- a/crates/pg_koldstore/src/spi.rs +++ b/crates/pg_koldstore/src/spi.rs @@ -20,6 +20,7 @@ fn sql_param_pg_oid(param: SqlParamType) -> pgrx::pg_sys::PgOid { let oid = match param { SqlParamType::BigInt => pgrx::pg_sys::INT8OID, SqlParamType::Integer => pgrx::pg_sys::INT4OID, + SqlParamType::TimestampWithTimeZone => pgrx::pg_sys::TIMESTAMPTZOID, SqlParamType::Text => pgrx::pg_sys::TEXTOID, SqlParamType::Jsonb => pgrx::pg_sys::JSONBOID, SqlParamType::Bytea => pgrx::pg_sys::BYTEAOID, diff --git a/crates/pg_koldstore/src/sql/events/mod.rs b/crates/pg_koldstore/src/sql/events/mod.rs index 87d0055c..456a98a6 100644 --- a/crates/pg_koldstore/src/sql/events/mod.rs +++ b/crates/pg_koldstore/src/sql/events/mod.rs @@ -18,7 +18,8 @@ use koldstore_merge::events::{self, DEFAULT_CHANGE_LIMIT}; use koldstore_merge::scan::SegmentStatsHint; #[cfg(feature = "pg")] use koldstore_parquet::{ - read_clean_cold_rows_from_object_store_with_size, CleanColdRow, ParquetReadOptions, PgColumn, + read_clean_cold_rows_from_object_store_with_size, CleanColdRow, ParquetProfileMode, + ParquetReadOptions, PgColumn, }; #[cfg(feature = "pg")] use pgrx::datum::DatumWithOid; @@ -229,57 +230,77 @@ fn fetch_since_seq_page( ); } - let mut page = Vec::with_capacity(limit); - let mut cursor = since_seq; - while page.len() < limit { - let Some(segment) = next_cold_segment(segments, cursor) else { - let rest = limit - page.len(); - let hot = fetch_hot_mirror_changes( - targets.table_oid.to_u32(), - targets.mirror, - targets.pk_names, - cursor, - rest, - targets.scope_column, - targets.scope_key, - )?; - page.extend(hot); - break; - }; + // Segment files may be packed by segment-order key (id/time), so seq ranges + // overlap across objects. Walk candidates by ascending min_seq and merge by + // seq; stop once no remaining segment can beat the current page cutoff. + let mut candidates: Vec<&SegmentStatsHint> = segments + .iter() + .filter(|segment| segment.max_seq.get() > since_seq) + .collect(); + candidates.sort_by(|left, right| { + ( + left.min_seq.get(), + left.max_seq.get(), + left.object_path.as_str(), + ) + .cmp(&( + right.min_seq.get(), + right.max_seq.get(), + right.object_path.as_str(), + )) + }); + + let mut cold_acc: Vec = Vec::new(); + for segment in candidates { + if cold_acc.len() >= limit { + cold_acc.sort_by_key(|row| row.seq); + cold_acc.truncate(limit); + if segment.min_seq.get() > cold_acc[limit - 1].seq.get() { + break; + } + } - let need = limit - page.len(); - let mut cold = read_cold_segment_page( + let cold = read_cold_segment_page( targets.table_oid, targets.snapshot, segment, - cursor, - need, + since_seq, + usize::MAX, targets.scope_key, )?; if cold.is_empty() { - // Stats prune / scope filter emptied this segment for the cursor — - // advance past it and try the next catalog candidate. - cursor = segment.max_seq.get(); continue; } - // Parquet batch order is not a cursor contract; advance by max seq and - // keep the page ascending so exclusive resume cannot skip/duplicate. - cold.sort_by_key(|row| row.seq); - cursor = cold.last().map(|row| row.seq.get()).unwrap_or(cursor); - page.extend(cold); - if page.len() >= limit { - break; - } - // Page still short: either the segment EOF'd under the row limit, or - // scope filtering dropped rows. Re-enter with the advanced cursor so - // the same segment can continue, or the next catalog candidate / mirror. + cold_acc.extend(cold); + } + + cold_acc.sort_by_key(|row| row.seq); + if cold_acc.len() > limit { + cold_acc.truncate(limit); } - page.sort_by_key(|row| row.seq); + let mut page = cold_acc; + if page.len() < limit { + let cursor = page.last().map(|row| row.seq.get()).unwrap_or(since_seq); + let hot = fetch_hot_mirror_changes( + targets.table_oid.to_u32(), + targets.mirror, + targets.pk_names, + cursor, + limit - page.len(), + targets.scope_column, + targets.scope_key, + )?; + page.extend(hot); + } Ok(page) } -/// Newest-N rewind: mirror first, then one newest cold segment if shortfall. +/// Newest-N rewind: hot mirror first, then newest cold segments until filled. +/// +/// Flush with `max_rows_per_file` writes many Parquet segments; the tip segment +/// alone may hold far fewer than `last_rows` keys. Walk segments newest-first +/// until the merged window reaches `last_rows` (or cold is exhausted). #[cfg(feature = "pg")] fn fetch_last_rows_page( targets: &ChangeFeedTargets<'_>, @@ -308,13 +329,7 @@ fn fetch_last_rows_page( .as_ref() .map(|ctx| ctx.segments.as_slice()) .unwrap_or(&[]); - let Some(newest) = segments.iter().max_by_key(|segment| { - ( - segment.max_seq.get(), - segment.min_seq.get(), - &segment.object_path, - ) - }) else { + if segments.is_empty() { return events::changes_last( &hot, targets.table_oid.to_u32(), @@ -322,19 +337,54 @@ fn fetch_last_rows_page( last_rows, ) .map_err(|error| error.to_string()); - }; + } - let need = (last_rows as usize).saturating_sub(hot.len()); - // Stream the newest segment with an ascending seq read, keep a bounded - // newest-N window (O(last_rows) memory, not O(segment)). - let cold = read_cold_segment_newest_window( - targets.table_oid, - targets.snapshot, - newest, - need, - targets.scope_key, - )?; - let mut combined = cold; + let mut ordered: Vec<&SegmentStatsHint> = segments.iter().collect(); + ordered.sort_by(|left, right| { + ( + right.max_seq.get(), + right.min_seq.get(), + right.object_path.as_str(), + ) + .cmp(&( + left.max_seq.get(), + left.min_seq.get(), + left.object_path.as_str(), + )) + }); + + let mut cold_acc = Vec::new(); + for segment in ordered { + let mut probe = cold_acc.clone(); + probe.extend(hot.iter().cloned()); + let have = events::changes_last( + &probe, + targets.table_oid.to_u32(), + targets.scope_key, + last_rows, + ) + .map_err(|error| error.to_string())? + .len(); + if have >= last_rows as usize { + break; + } + + // Up to `last_rows` newest keys from this segment; older tip segments + // fill any remaining shortfall after PK merge with hot. + let chunk = read_cold_segment_newest_window( + targets.table_oid, + targets.snapshot, + segment, + last_rows as usize, + targets.scope_key, + )?; + if chunk.is_empty() { + continue; + } + cold_acc.extend(chunk); + } + + let mut combined = cold_acc; combined.extend(hot); events::changes_last( &combined, @@ -347,6 +397,7 @@ fn fetch_last_rows_page( /// Oldest published segment that can still contribute rows after `since_seq`. #[cfg(feature = "pg")] +#[allow(dead_code)] // retained for seq-partitioned segment walks / future prune helpers fn next_cold_segment(segments: &[SegmentStatsHint], since_seq: i64) -> Option<&SegmentStatsHint> { segments .iter() @@ -506,11 +557,15 @@ fn read_cold_segment_page( let store = client.store(); let min_seq = SeqId::new(since_seq.saturating_add(1).max(1)).map_err(|e| e.to_string())?; + // Do not push `row_limit` into the Parquet reader: segment row order follows + // the configured segment-order column (often PK/id), not mirror `seq`. An + // early stop mid-file would advance the exclusive seq cursor past unread + // lower-seq rows in the same segment and silently drop change-feed history. let options = ParquetReadOptions::new() .with_columns(projection.physical_names.clone()) .with_clean_seq_range(min_seq, segment.max_seq) - .with_row_limit(limit) - .with_timeout(client.timeout()); + .with_timeout(client.timeout()) + .with_profile_mode(ParquetProfileMode::Disabled); let _permit = crate::merge_scan::reader_pool::try_acquire_parquet_reader_permit( crate::guc::max_open_parquet_readers(), @@ -543,6 +598,10 @@ fn read_cold_segment_page( } changes.push(change); } + changes.sort_by_key(|change| change.seq); + if changes.len() > limit { + changes.truncate(limit); + } // Scope filter can drop rows after the reader already early-stopped; that // is acceptable — the next page advances by the last emitted seq. Ok(changes) @@ -582,7 +641,8 @@ fn read_cold_segment_newest_window( let options = ParquetReadOptions::new() .with_columns(projection.physical_names.clone()) - .with_timeout(client.timeout()); + .with_timeout(client.timeout()) + .with_profile_mode(ParquetProfileMode::Disabled); let _permit = crate::merge_scan::reader_pool::try_acquire_parquet_reader_permit( crate::guc::max_open_parquet_readers(), )?; diff --git a/crates/pg_koldstore/src/sql/flush/execute.rs b/crates/pg_koldstore/src/sql/flush/execute.rs index 6411e06b..fd351dd5 100644 --- a/crates/pg_koldstore/src/sql/flush/execute.rs +++ b/crates/pg_koldstore/src/sql/flush/execute.rs @@ -68,6 +68,9 @@ pub(super) struct FlushPreparedContext { bloom_filter_columns: Vec, max_rows_per_file: usize, target_file_size_bytes: Option, + parquet_row_group_size: usize, + parquet_data_page_row_count_limit: Option, + parquet_bloom_filter_false_positive_rate: Option, } /// Acquires the session table-job lock without waiting. @@ -148,6 +151,20 @@ fn load_flush_prepared_context( .ok_or_else(|| format!("target_file_size_mb {megabytes} is too large")) }) .transpose()?; + let parquet_row_group_size = options + .parquet_row_group_size + .map(usize::try_from) + .transpose() + .map_err(|error| format!("parquet_row_group_size is too large: {error}"))? + .unwrap_or_else(|| koldstore_parquet::WriterOptions::default().row_group_size); + let parquet_data_page_row_count_limit = options + .parquet_data_page_row_count_limit + .map(usize::try_from) + .transpose() + .map_err(|error| format!("parquet_data_page_row_count_limit is too large: {error}"))?; + let parquet_bloom_filter_false_positive_rate = options + .parquet_bloom_filter_fpp + .map(koldstore_common::ParquetBloomFilterFpp::get); if let Some(cold) = cold_metadata.as_ref() { if !cold.stats_columns.is_empty() { // Start from catalog stats, then force PK + order column so Exact-PK @@ -211,6 +228,9 @@ fn load_flush_prepared_context( bloom_filter_columns, max_rows_per_file, target_file_size_bytes, + parquet_row_group_size, + parquet_data_page_row_count_limit, + parquet_bloom_filter_false_positive_rate, }) } @@ -323,7 +343,9 @@ fn stream_write_flush_batches( fetch_batch_size: flush_mirror_fetch_limit(ctx.max_rows_per_file), target_file_size_bytes: ctx.target_file_size_bytes, compression: ctx.storage.compression.clone(), - row_group_size: koldstore_parquet::WriterOptions::default().row_group_size, + row_group_size: ctx.parquet_row_group_size, + data_page_row_count_limit: ctx.parquet_data_page_row_count_limit, + bloom_filter_false_positive_rate: ctx.parquet_bloom_filter_false_positive_rate, mirror_ops: selection.mirror_ops.clone(), sort_by_order_key: ctx.snapshot.segment_order_column_id.is_some(), order_key_column: ctx.snapshot.segment_order_column_id.and_then(|column_id| { @@ -552,7 +574,7 @@ pub(super) fn finalize_flush( outcome: &TableFlushBatchOutcome, client: &koldstore_storage::ObjectStoreClient, ) -> Result<(), String> { - // One critical section under try-lock slot ownership: prelock catch-up, + // One critical section under slot-lock ownership: prelock catch-up, // manifest write, activate, source fence, prune. Encode/upload already // finished without the slot lock. with_slot_lock_retry(|| { @@ -624,25 +646,25 @@ pub(super) fn finalize_flush( /// /// Callers run finalize fence work while the lock is held. Nested apply uses /// [`apply_bounded_locked`] so we do not depend on re-entrant blocking lock. +/// The WAL applier also try-locks and yields when this waiter holds the lock. fn with_slot_lock_retry(body: impl FnOnce() -> Result) -> Result { - use crate::mirror::lifecycle::try_lock_slot; + use crate::mirror::lifecycle::{try_lock_slot, SLOT_LOCK_WAIT}; let database_oid = unsafe { pgrx::pg_sys::MyDatabaseId }.to_u32(); - // Bound wait so finalize never blocks forever on a stuck applier, but allow - // several seconds under parallel E2E / busy apply (former ~0.8s budget flaked). - const MAX_ATTEMPTS: u32 = 200; + let deadline = std::time::Instant::now() + SLOT_LOCK_WAIT; const SLEEP_MS: u64 = 50; - for attempt in 1..=MAX_ATTEMPTS { - crate::failpoints::hit_typed(crate::failpoints::FlushFailpoint::BeforeSlotLock)?; + crate::failpoints::hit_typed(crate::failpoints::FlushFailpoint::BeforeSlotLock)?; + loop { if try_lock_slot(database_oid)? { crate::failpoints::hit_typed(crate::failpoints::FlushFailpoint::AfterSlotLock)?; return body(); } - if attempt == MAX_ATTEMPTS { + if std::time::Instant::now() >= deadline { break; } - pgrx::log!("koldstore flush: slot lock busy (attempt {attempt}/{MAX_ATTEMPTS}); retrying"); + pgrx::log!("koldstore flush: slot lock busy; retrying"); std::thread::sleep(std::time::Duration::from_millis(SLEEP_MS)); + pgrx::check_for_interrupts!(); } Err("flush finalize could not acquire slot lock before deadline".to_string()) } diff --git a/crates/pg_koldstore/src/sql/migrate/manage.rs b/crates/pg_koldstore/src/sql/migrate/manage.rs index d5c72d8b..85af3889 100644 --- a/crates/pg_koldstore/src/sql/migrate/manage.rs +++ b/crates/pg_koldstore/src/sql/migrate/manage.rs @@ -33,6 +33,9 @@ pub(crate) fn manage_table_pg_impl( segment_order_column: Option<&str>, pruning_columns: Option>, bloom_filter_columns: Option>, + parquet_row_group_size: Option, + parquet_data_page_row_count_limit: Option, + parquet_bloom_filter_fpp: Option, ) -> pgrx::Uuid { crate::preload::require_shared_preload(); let min_max_rows_per_file = u64::try_from(crate::guc::min_max_rows_per_file()) @@ -45,6 +48,9 @@ pub(crate) fn manage_table_pg_impl( target_file_size_mb, min_max_rows_per_file, auto_flush, + parquet_row_group_size, + parquet_data_page_row_count_limit, + parquet_bloom_filter_fpp, }, compression, ) @@ -70,6 +76,13 @@ pub(crate) fn manage_table_pg_impl( .unwrap_or_else(|error| pgrx::error!("migrate table failed: {error}")); let already_managed = table_is_already_managed(table_oid) .unwrap_or_else(|error| pgrx::error!("migrate table failed: {error}")); + // A stable migration order is also the natural physical cold order. Keep + // an explicit segment order authoritative, but default it so ordered reads + // receive their cold frontier/index metadata without duplicate arguments. + let segment_order_column = koldstore_migrate::manage_table::effective_segment_order_column( + segment_order_column, + migration_order_by, + ); let validation = koldstore_migrate::manage_table::validate_manage_table(manage_table_validation_context( table_type, @@ -86,6 +99,9 @@ pub(crate) fn manage_table_pg_impl( auto_flush, pruning_columns.as_deref(), bloom_filter_columns.as_deref(), + parquet_row_group_size, + parquet_data_page_row_count_limit, + parquet_bloom_filter_fpp, catalog.as_ref(), constraints, )) @@ -131,6 +147,14 @@ pub(crate) fn manage_table_pg_impl( order_column_name, ) .unwrap_or_else(|error| pgrx::error!("migrate table failed: {error}")); + if crate::catalog::resolve::mirror_has_other_active_owner(table_oid, &mirror_plan.mirror_table) + .unwrap_or_else(|error| pgrx::error!("migrate table failed: {error}")) + { + pgrx::error!( + "migrate table failed: mirror {} is already owned by another active managed table", + mirror_plan.mirror_table.quoted() + ); + } if !has_existing_rows { for statement in mirror_plan.create_statements() { pgrx::Spi::run(&statement.sql) @@ -255,6 +279,7 @@ pub(crate) fn manage_table_pg_impl( &plan, &mirror_plan, &primary_key_shape, + order_column_name, job_id, ) .unwrap_or_else(|error| pgrx::error!("migrate table failed: {error}")); @@ -377,6 +402,9 @@ fn manage_table_validation_context<'a>( auto_flush: bool, pruning_columns: Option<&'a [String]>, bloom_filter_columns: Option<&'a [String]>, + parquet_row_group_size: Option, + parquet_data_page_row_count_limit: Option, + parquet_bloom_filter_fpp: Option, catalog: &'a koldstore_migrate::ExistingTableCatalog, constraints: koldstore_migrate::constraints::ManageTableConstraintsCatalog, ) -> koldstore_migrate::manage_table::ManageTableValidationContext<'a> { @@ -459,6 +487,9 @@ fn manage_table_validation_context<'a>( target_file_size_mb, min_max_rows_per_file, auto_flush, + parquet_row_group_size, + parquet_data_page_row_count_limit, + parquet_bloom_filter_fpp, }, pruning_columns, bloom_filter_columns, diff --git a/crates/pg_koldstore/src/sql/migrate/migration_jobs.rs b/crates/pg_koldstore/src/sql/migrate/migration_jobs.rs index 2abc21f7..694a2fc4 100644 --- a/crates/pg_koldstore/src/sql/migrate/migration_jobs.rs +++ b/crates/pg_koldstore/src/sql/migrate/migration_jobs.rs @@ -122,14 +122,16 @@ pub(super) fn run_existing_table_mirror_initialization_inline( plan: &koldstore_migrate::ExistingTableMigrationPlan, mirror_plan: &koldstore_migrate::ChangeLogMirrorPlan, primary_key_shape: &koldstore_common::PrimaryKeyShape, + segment_order_column: Option<&str>, job_id: Uuid, ) -> Result { - let batch = koldstore_migrate::backfill::plan_mirror_initialization_batch( + let batch = koldstore_migrate::backfill::plan_mirror_initialization_batch_with_segment_order( &plan.table, &mirror_plan.mirror_table, primary_key_shape.columns(), plan.ordering.clone(), plan.backfill_batch_size, + segment_order_column, ) .map_err(|error| error.to_string())?; let mut processed_rows = 0_i64; diff --git a/crates/pg_koldstore/src/sql/migrate/mod.rs b/crates/pg_koldstore/src/sql/migrate/mod.rs index 1f8a567d..fcebbe1e 100644 --- a/crates/pg_koldstore/src/sql/migrate/mod.rs +++ b/crates/pg_koldstore/src/sql/migrate/mod.rs @@ -24,7 +24,9 @@ pub(crate) use manage::manage_table_pg_impl; #[cfg(feature = "pg")] use manage::set_table_auto_flush_pg_impl; #[cfg(feature = "pg")] -pub(crate) use schema_registry::refresh_active_schema_if_changed; +pub(crate) use schema_registry::{ + refresh_active_schema_if_changed, sync_active_mirror_relation_names_in_schema, +}; /// A SQL `regclass` argument decoded without opening or locking the relation. /// @@ -61,7 +63,9 @@ pgrx::impl_sql_translatable!(RegClassOid, arg_only = "regclass"); /// Manages a heap table with structured hot/cold flush settings. /// /// SQL contract: -/// `koldstore.manage_table(table_name regclass, storage, hot_row_limit, min_flush_rows default 1000, max_rows_per_file default 1000, table_type default 'shared', scope_column default null, migration_order_by default null, compression default null, target_file_size_mb default null, auto_flush default true, segment_order_column default null)`. +/// `koldstore.manage_table(table_name regclass, storage, hot_row_limit, min_flush_rows default 1000, max_rows_per_file default 1000, table_type default 'shared', scope_column default null, migration_order_by default null, compression default null, target_file_size_mb default null, auto_flush default true, segment_order_column default null, pruning_columns default null, bloom_filter_columns default null, parquet_row_group_size default null, parquet_data_page_row_count_limit default null, parquet_bloom_filter_fpp default null)`. +/// When `segment_order_column` is omitted, `migration_order_by` is used for +/// both migration and cold-segment ordering. /// /// `table_name` is PostgreSQL `regclass`, so relation names like `'app.messages'` /// cast correctly (plain `oid` would reject that string). @@ -84,6 +88,9 @@ pub fn manage_table_pg( segment_order_column: pgrx::default!(Option<&str>, "NULL"), pruning_columns: pgrx::default!(Option>, "NULL"), bloom_filter_columns: pgrx::default!(Option>, "NULL"), + parquet_row_group_size: pgrx::default!(Option, "NULL"), + parquet_data_page_row_count_limit: pgrx::default!(Option, "NULL"), + parquet_bloom_filter_fpp: pgrx::default!(Option, "NULL"), ) -> pgrx::Uuid { manage::manage_table_pg_impl( table_name.0, @@ -100,6 +107,9 @@ pub fn manage_table_pg( segment_order_column, pruning_columns, bloom_filter_columns, + parquet_row_group_size, + parquet_data_page_row_count_limit, + parquet_bloom_filter_fpp, ) } diff --git a/crates/pg_koldstore/src/sql/migrate/schema_registry.rs b/crates/pg_koldstore/src/sql/migrate/schema_registry.rs index 04d8c690..1e53df95 100644 --- a/crates/pg_koldstore/src/sql/migrate/schema_registry.rs +++ b/crates/pg_koldstore/src/sql/migrate/schema_registry.rs @@ -144,7 +144,7 @@ pub(crate) fn refresh_active_schema_if_changed( }) .map_err(|error| error.to_string())?; if action == koldstore_schema::SchemaEvolutionAction::Unchanged { - return Ok(false); + return sync_active_mirror_relation_name(table_oid); } let primary_key_shape = primary_key_shape(table_oid_u32)?; @@ -165,6 +165,132 @@ pub(crate) fn refresh_active_schema_if_changed( Ok(true) } +/// Renames a managed mirror when its source's schema-qualified name changes. +/// +/// PostgreSQL preserves a relation OID across `ALTER TABLE ... RENAME` and +/// `ALTER TABLE ... SET SCHEMA`, but mirror names encode that source identity. +/// Rehoming all generated artifacts frees the old source name for safe reuse. +#[cfg(feature = "pg")] +pub(crate) fn sync_active_mirror_relation_name( + table_oid: pgrx::pg_sys::Oid, +) -> Result { + let Some(active) = active_schema_refresh_context(table_oid)? else { + return Ok(false); + }; + let source_name = crate::catalog::resolve::qualified_relation_name(table_oid)?; + let source = koldstore_migrate::QualifiedTableName::parse(&source_name) + .map_err(|error| error.to_string())?; + let old_mirror = koldstore_migrate::QualifiedTableName::parse(&active.mirror_relation) + .map_err(|error| error.to_string())?; + let new_mirror = koldstore_migrate::mirror_relation_for_source(&source) + .map_err(|error| error.to_string())?; + if old_mirror == new_mirror { + return Ok(false); + } + if crate::catalog::resolve::mirror_has_other_active_owner(table_oid, &old_mirror)? { + return Err(format!( + "refusing to rename managed mirror {} for {source_name}: it is still referenced by another active managed table", + old_mirror.quoted() + )); + } + + let old_storage = koldstore_wal_mirror::MirrorRelation::new( + old_mirror + .as_table_name() + .map_err(|error| error.to_string())?, + ); + let new_storage = koldstore_wal_mirror::MirrorRelation::new( + new_mirror + .as_table_name() + .map_err(|error| error.to_string())?, + ); + for statement in koldstore_wal_mirror::plan_mirror_relation_rename(&old_storage, &new_storage) + .map_err(|error| error.to_string())? + { + pgrx::Spi::run(&statement.sql).map_err(|error| error.to_string())?; + } + + let catalog = load_migration_catalog(table_oid.to_u32())?; + let primary_key_shape = primary_key_shape(table_oid.to_u32())?; + let options: koldstore_common::ManageTableOptions = + serde_json::from_value(active.options.clone()).unwrap_or_default(); + let order_column = options.segment_order_column_id.and_then(|column_id| { + catalog + .columns + .iter() + .find(|column| column.column_id.get() == column_id) + .map(|column| column.name.as_str()) + }); + for statement in koldstore_wal_mirror::plan_mirror_source_teardown(&source, &old_mirror) + .map_err(|error| error.to_string())? + { + pgrx::Spi::run(&statement.sql).map_err(|error| error.to_string())?; + } + let pk_guard = koldstore_wal_mirror::plan_mirror_pk_guard( + &source, + &new_mirror, + primary_key_shape.columns(), + order_column, + ) + .map_err(|error| error.to_string())?; + for statement in pk_guard.create_statements() { + pgrx::Spi::run(&statement.sql).map_err(|error| error.to_string())?; + } + crate::mirror::lifecycle::activate_table( + &source, + &new_mirror, + &primary_key_shape, + order_column, + )?; + crate::catalog::cache::invalidate_table_globally(table_oid); + crate::spi::invalidate_all_prepared_plans(); + Ok(true) +} + +/// Rehomes every active managed mirror whose source table is in `schema_name`. +/// +/// # Errors +/// +/// Returns an error when catalog lookup or any mirror rename operation fails. +#[cfg(feature = "pg")] +pub(crate) fn sync_active_mirror_relation_names_in_schema( + schema_name: &str, +) -> Result { + use pgrx::datum::DatumWithOid; + + let table_oids = pgrx::Spi::connect(|client| -> Result, String> { + let rows = client + .select( + "SELECT s.table_oid::oid \ + FROM koldstore.schemas s \ + JOIN pg_catalog.pg_class c ON c.oid = s.table_oid \ + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ + WHERE s.active AND n.nspname = $1 \ + ORDER BY s.table_oid", + None, + &[DatumWithOid::from(schema_name)], + ) + .map_err(|error| error.to_string())?; + let mut table_oids = Vec::new(); + for row in rows { + if let Some(table_oid) = row + .get::(1) + .map_err(|error| error.to_string())? + { + table_oids.push(table_oid); + } + } + Ok(table_oids) + }) + .map_err(|error| error.to_string())?; + + let mut renamed = 0; + for table_oid in table_oids { + renamed += usize::from(sync_active_mirror_relation_name(table_oid)?); + } + Ok(renamed) +} + #[cfg(feature = "pg")] fn active_schema_refresh_context( table_oid: pgrx::pg_sys::Oid, diff --git a/crates/pg_koldstore/src/sql/migrate/unmanage.rs b/crates/pg_koldstore/src/sql/migrate/unmanage.rs index 741ba919..40b96b99 100644 --- a/crates/pg_koldstore/src/sql/migrate/unmanage.rs +++ b/crates/pg_koldstore/src/sql/migrate/unmanage.rs @@ -17,6 +17,14 @@ pub(super) fn unmanage_table_pg_impl( let table = koldstore_migrate::QualifiedTableName::parse(&relation) .map_err(|error| error.to_string())?; let mirror_table = crate::catalog::resolve::mirror_relation_by_table_oid(table_oid)?; + if let Some(mirror_table) = &mirror_table { + if crate::catalog::resolve::mirror_has_other_active_owner(table_oid, mirror_table)? { + return Err(format!( + "refusing to unmanage {relation}: mirror {} is still referenced by another active managed table", + mirror_table.quoted() + )); + } + } let context = demigration_context( table, koldstore_common::TableOid::from_raw(table_oid_u32), @@ -66,6 +74,10 @@ fn execute_demigration_statements( ) -> Result { use pgrx::datum::DatumWithOid; + // Rehydrate issues `TRUNCATE TABLE ONLY ` before catalog deactivation. + // The ProcessUtility TRUNCATE guard must allow that internal path only. + let _allow_truncate = crate::hooks::ddl::AllowManagedTruncateGuard::enter(); + let statement_count = plan.statements.len(); let mut deactivated = 0_i64; diff --git a/crates/pg_koldstore/src/sql/storage/mod.rs b/crates/pg_koldstore/src/sql/storage/mod.rs index 4efb2994..607e56dd 100644 --- a/crates/pg_koldstore/src/sql/storage/mod.rs +++ b/crates/pg_koldstore/src/sql/storage/mod.rs @@ -12,10 +12,13 @@ use koldstore_storage::registration::*; /// regular_path_tmpl, scoped_path_tmpl, check default true)`. /// /// When `check` is true (default), opens the configured backend and performs a -/// put/delete probe (filesystem roots are created first). Pass `check => false` -/// to skip (for example when credentials or mounts will be available later). +/// put/delete probe (filesystem roots are created first and must be empty). +/// Pass `check => false` to skip emptiness and writability checks (for example +/// when intentionally reusing a non-empty directory, or when credentials or +/// mounts will be available later). /// -/// Errors when `name` already exists. +/// Errors when `name` already exists, when `check` is true and a filesystem +/// `base_path` is non-empty, or when the writability probe fails. #[cfg(feature = "pg")] #[allow(clippy::too_many_arguments)] #[pgrx::pg_extern(name = "register_storage", schema = "koldstore", security_definer)] @@ -48,9 +51,11 @@ pub fn register_storage_pg( /// check default true)`. /// /// When `check` is true (default), opens the configured backend and performs a -/// put/delete probe. Pass `check => false` to skip. +/// put/delete probe (filesystem roots are created first and must be empty). +/// Pass `check => false` to skip emptiness and writability checks. /// -/// Errors when `name` already exists. +/// Errors when `name` already exists, when `check` is true and a filesystem +/// `base_path` is non-empty, or when the writability probe fails. #[cfg(feature = "pg")] #[pgrx::pg_extern(name = "register_storage", schema = "koldstore", security_definer)] pub fn register_storage_pg_with_default_templates( @@ -174,7 +179,8 @@ pub fn alter_storage_credentials_pg(name: &str, credentials: pgrx::JsonB) { /// `koldstore.alter_storage_location(name, base_path, config, check default true)`. /// /// When `check` is true (default), opens the backend with existing credentials -/// and probes put/delete at the new location. Pass `check => false` to skip. +/// and probes put/delete at the new location (filesystem roots must be empty). +/// Pass `check => false` to skip emptiness and writability checks. #[cfg(feature = "pg")] #[pgrx::pg_extern( name = "alter_storage_location", diff --git a/crates/pg_koldstore/src/worker/flush_executor.rs b/crates/pg_koldstore/src/worker/flush_executor.rs index fd356d05..2945b3f2 100644 --- a/crates/pg_koldstore/src/worker/flush_executor.rs +++ b/crates/pg_koldstore/src/worker/flush_executor.rs @@ -8,7 +8,10 @@ use std::time::Duration; use koldstore_common::unix_now_ms; -use koldstore_flush::{plan_next_pending_flush_due_epoch_ms, plan_select_pending_flush_candidates}; +use koldstore_flush::{ + plan_next_pending_flush_due_epoch_ms, plan_select_pending_flush_candidates, + plan_select_pending_flush_candidates_after, +}; use koldstore_supervisor::{flush_executor_worker_type, DatabaseOid, LIBRARY_NAME}; use pgrx::bgworkers::{BackgroundWorker, BackgroundWorkerBuilder}; use pgrx::datum::DatumWithOid; @@ -61,35 +64,62 @@ pub(crate) fn register_flush_executor_from_supervisor(database_oid: u32) -> Resu struct PendingCandidate { table_oid: pgrx::pg_sys::Oid, force: bool, + cursor: PendingCandidateCursor, } -fn pending_candidates() -> Result, String> { - let statement = plan_select_pending_flush_candidates().map_err(|error| error.to_string())?; - pgrx::Spi::connect(|client| { - let table = client - .select( - &statement.sql, - // SQL already supplies the hard page bound. Do not pass Some(1) - // here: that silently collapsed the intended fair page to one - // candidate and reintroduced head-of-line blocking. - None, - &[DatumWithOid::from(CANDIDATE_PAGE_SIZE)], - ) - .map_err(|error| error.to_string())?; +#[derive(Debug, Clone, Copy)] +struct PendingCandidateCursor { + available_at: pgrx::datum::TimestampWithTimeZone, + updated_at: pgrx::datum::TimestampWithTimeZone, + id: pgrx::Uuid, +} + +fn pending_candidates( + after: Option, +) -> Result, String> { + let statement = match after { + Some(_) => { + plan_select_pending_flush_candidates_after().map_err(|error| error.to_string())? + } + None => plan_select_pending_flush_candidates().map_err(|error| error.to_string())?, + }; + let mut args = vec![DatumWithOid::from(CANDIDATE_PAGE_SIZE)]; + if let Some(after) = after { + args.extend([ + DatumWithOid::from(after.available_at), + DatumWithOid::from(after.updated_at), + DatumWithOid::from(after.id), + ]); + } + crate::spi::execute_prepared(&statement, &args, |table| { let mut candidates = Vec::with_capacity(CANDIDATE_PAGE_SIZE as usize); for row in table { let table_oid = row - .get::(1) - .map_err(|error| error.to_string())? - .ok_or_else(|| "pending flush candidate missing table_oid".to_string())?; - let force = row - .get::(2) - .map_err(|error| error.to_string())? - .unwrap_or(false); - candidates.push(PendingCandidate { table_oid, force }); + .get::(1)? + .ok_or_else(|| crate::spi::missing_attribute("table_oid"))?; + let force = row.get::(2)?.unwrap_or(false); + let available_at = row + .get::(3)? + .ok_or_else(|| crate::spi::missing_attribute("available_at"))?; + let updated_at = row + .get::(4)? + .ok_or_else(|| crate::spi::missing_attribute("updated_at"))?; + let id = row + .get::(5)? + .ok_or_else(|| crate::spi::missing_attribute("id"))?; + candidates.push(PendingCandidate { + table_oid, + force, + cursor: PendingCandidateCursor { + available_at, + updated_at, + id, + }, + }); } Ok(candidates) }) + .map_err(|error| error.to_string()) } fn next_pending_due_ms() -> Result, String> { @@ -111,41 +141,54 @@ enum ClaimOutcome { } fn claim_one_flush_job() -> Result<(ClaimOutcome, Option), String> { - let candidates = pending_candidates()?; - if candidates.is_empty() { - return Ok((ClaimOutcome::Empty, None)); - } + let mut after = None; + let mut saw_candidate = false; - for candidate in candidates { - let Some(guard) = crate::sql::job_lock::TableJobLockGuard::try_lock(candidate.table_oid)? - else { - continue; - }; - match crate::sql::flush::execute::claim_flush_job_for_executor( - candidate.table_oid, - candidate.force, - ) { - Ok(claimed) => { - return Ok(( - ClaimOutcome::Claimed, - Some(ClaimedWork { - table_oid: candidate.table_oid, - guard, - claimed, - }), - )); - } - Err(error) => { - pgrx::log!( - "koldstore flush executor: candidate table_oid={} changed before claim: {error}", - candidate.table_oid.to_u32() - ); - drop(guard); + loop { + let candidates = pending_candidates(after)?; + if candidates.is_empty() { + return Ok(( + if saw_candidate { + ClaimOutcome::Busy + } else { + ClaimOutcome::Empty + }, + None, + )); + } + saw_candidate = true; + after = candidates.last().map(|candidate| candidate.cursor); + + for candidate in candidates { + let Some(guard) = + crate::sql::job_lock::TableJobLockGuard::try_lock(candidate.table_oid)? + else { + continue; + }; + match crate::sql::flush::execute::claim_flush_job_for_executor( + candidate.table_oid, + candidate.force, + ) { + Ok(claimed) => { + return Ok(( + ClaimOutcome::Claimed, + Some(ClaimedWork { + table_oid: candidate.table_oid, + guard, + claimed, + }), + )); + } + Err(error) => { + pgrx::log!( + "koldstore flush executor: candidate table_oid={} changed before claim: {error}", + candidate.table_oid.to_u32() + ); + drop(guard); + } } } } - - Ok((ClaimOutcome::Busy, None)) } struct FlushWorkerRegistration { diff --git a/crates/pg_koldstore/src/worker/wal.rs b/crates/pg_koldstore/src/worker/wal.rs index 324d4f4d..bf488d5e 100644 --- a/crates/pg_koldstore/src/worker/wal.rs +++ b/crates/pg_koldstore/src/worker/wal.rs @@ -15,12 +15,15 @@ use koldstore_wal_mirror::{ use pgrx::bgworkers::{BackgroundWorker, BackgroundWorkerBuilder, SignalWakeFlags}; use pgrx::{pg_guard, pg_shmem_init, pg_sys, AssertPGRXSharedMemory, PgAtomic}; -use crate::mirror::apply::{apply_bounded, capture_durable_wal_fence, BoundedApplyRequest}; +use crate::mirror::apply::{apply_bounded_locked, capture_durable_wal_fence, BoundedApplyRequest}; +use crate::mirror::lifecycle::try_lock_slot; const WAL_APPLIER_FUNCTION: &str = "koldstore_wal_applier_main"; const WAL_APPLIER_WATCHDOG: Duration = Duration::from_secs(30); const APPLY_RETRY_MIN: Duration = Duration::from_millis(100); const APPLY_RETRY_MAX: Duration = Duration::from_secs(5); +/// Pause when flush finalize holds the slot lock so try-lock waiters can run. +const APPLIER_LOCK_YIELD: Duration = Duration::from_millis(10); type SharedWalApplierRegistry = AssertPGRXSharedMemory>; @@ -277,20 +280,41 @@ fn process_sighup() { /// worker performs XLogFlush, not the application backend. fn drain_wal_through_fixed_fence() -> Result<(), String> { let fence = capture_durable_wal_fence()?; + let database_oid = unsafe { pgrx::pg_sys::MyDatabaseId }.to_u32(); loop { let decoding_log_guard = DecodingLogGuard::suppress_routine_log_messages(); let outcome = super::txn::run_recoverable("WAL applier", || { + if !try_lock_slot(database_oid)? { + return Ok(None); + } let mut request = BoundedApplyRequest::available(); request.upper_bound = Some(fence); request.advance_slot_on_empty = true; - apply_bounded(request) + apply_bounded_locked(request).map(Some) }); drop(decoding_log_guard); let outcome = outcome?; + let Some(outcome) = outcome else { + // Flush finalize holds the slot lock (not a heap lock). Yield so + // prune can finish; do not mark this generation processed. + if !wait_until(Instant::now() + APPLIER_LOCK_YIELD) { + return Err("WAL applier stopping while slot lock is held".to_string()); + } + continue; + }; crate::observability::record_async_apply_tick(outcome.row_changes, 0); - if !outcome.budget_exhausted { + if outcome.budget_exhausted { + continue; + } + if outcome.row_changes == 0 { return Ok(()); } + // The apply transaction just committed `applied_lsn`, but advancing a + // logical slot before that commit would risk acknowledging mirror work + // that can still roll back (and SPI from XACT_EVENT_COMMIT is unsafe). + // Run one immediate empty pass so the durable checkpoint is + // acknowledged now instead of waiting for the 30-second recovery + // watchdog (or an unrelated future commit). } } diff --git a/docker/Dockerfile b/docker/Dockerfile index 2c92bdfa..3b9a7d5a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,12 +7,14 @@ FROM rust:1.96-bookworm AS builder ARG PG_MAJOR ARG PGRX_VERSION=0.19.1 +COPY scripts/ci/keys/ACCC4CF8.asc /tmp/ACCC4CF8.asc + RUN apt-get update \ && apt-get install -y --no-install-recommends \ wget ca-certificates gnupg lsb-release \ build-essential libssl-dev pkg-config clang llvm \ - && wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg \ + && gpg --batch --yes --dearmor -o /usr/share/keyrings/postgresql.gpg /tmp/ACCC4CF8.asc \ + && rm -f /tmp/ACCC4CF8.asc \ && echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ @@ -26,7 +28,9 @@ COPY . . ENV CARGO_BUILD_JOBS=1 \ CARGO_PROFILE_RELEASE_PG_LTO=false \ - CARGO_PROFILE_RELEASE_PG_CODEGEN_UNITS=16 + CARGO_PROFILE_RELEASE_PG_CODEGEN_UNITS=16 \ + RUSTUP_TOOLCHAIN=1.96.0 \ + CARGO_ENCODED_RUSTFLAGS= RUN cargo pgrx init --pg"${PG_MAJOR}" "/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config" \ && cargo pgrx package \ diff --git a/docker/Dockerfile.release b/docker/Dockerfile.release index ed11822c..df8a10ed 100644 --- a/docker/Dockerfile.release +++ b/docker/Dockerfile.release @@ -1,20 +1,32 @@ # syntax=docker/dockerfile:1 # -# Release runtime — PostgreSQL 16 + prebuilt koldstore + pg_cron. +# Release runtime — PostgreSQL + prebuilt koldstore + pg_cron. # Does not compile the extension; copy artifacts from the release tarball. # # Build context: repository root, with extracted extension files in # docker-artifacts/ (koldstore.so, koldstore.control, koldstore--*.sql). # -# docker build -f docker/Dockerfile.release -t pg-koldstore:16 . +# # PostgreSQL 18 (default / :latest) +# docker build -f docker/Dockerfile.release \ +# --build-arg PG_MAJOR=18 \ +# -t pg-koldstore:18 . +# +# # PostgreSQL 16 (still published as :-pg16) +# docker build -f docker/Dockerfile.release \ +# --build-arg PG_MAJOR=16 \ +# --build-arg OCI_IMAGE_DESCRIPTION="PostgreSQL 16 with koldstore preinstalled for trying KoldStore quickly." \ +# -t pg-koldstore:16 . # # Base is Ubuntu 24.04 + PGDG PostgreSQL so the prebuilt ubuntu24.04 -# package (amd64 or arm64) matches the runtime glibc (official -# postgres:16-bookworm would not). Build each arch with its matching -# docker-artifacts/koldstore.so; release CI publishes a multi-arch manifest. +# package (amd64 or arm64) matches the runtime glibc. Build each arch with +# its matching docker-artifacts/koldstore.so; release CI publishes multi-arch +# manifests for both PG majors (`latest` tracks PG18). + +ARG PG_MAJOR=18 +ARG OCI_IMAGE_DESCRIPTION="PostgreSQL 18 with koldstore preinstalled for trying KoldStore quickly." -ARG PG_MAJOR=16 -ARG OCI_IMAGE_DESCRIPTION="PostgreSQL 16 with koldstore preinstalled for trying KoldStore quickly." +# Official entrypoint scripts (initdb, POSTGRES_* env, init hooks). +FROM postgres:${PG_MAJOR}-bookworm AS official_postgres FROM ubuntu:24.04 @@ -33,6 +45,9 @@ ENV DEBIAN_FRONTEND=noninteractive \ LANGUAGE=en_US:en \ LC_ALL=en_US.UTF-8 +# Vendored PGDG signing key (avoids flaky www.postgresql.org fetches at build time). +COPY scripts/ci/keys/ACCC4CF8.asc /tmp/ACCC4CF8.asc + RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ @@ -46,8 +61,9 @@ RUN set -eux; \ tzdata; \ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ locale-gen; \ - curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --batch --yes --dearmor -o /usr/share/keyrings/postgresql.gpg; \ + install -d /usr/share/keyrings; \ + gpg --batch --yes --dearmor -o /usr/share/keyrings/postgresql.gpg /tmp/ACCC4CF8.asc; \ + rm -f /tmp/ACCC4CF8.asc; \ echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ > /etc/apt/sources.list.d/pgdg.list; \ apt-get update; \ @@ -64,13 +80,13 @@ RUN set -eux; \ chown -R postgres:postgres /var/lib/postgresql /var/run/postgresql; \ chmod 2777 /var/run/postgresql -# Official entrypoint (handles initdb, POSTGRES_* env, init scripts). -COPY --from=postgres:16-bookworm /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ -COPY --from=postgres:16-bookworm /usr/local/bin/docker-ensure-initdb.sh /usr/local/bin/ +COPY --from=official_postgres /usr/local/bin/docker-entrypoint.sh /usr/local/bin/ +COPY --from=official_postgres /usr/local/bin/docker-ensure-initdb.sh /usr/local/bin/ COPY docker-artifacts/koldstore.so /tmp/koldstore/koldstore.so COPY docker-artifacts/koldstore.control /tmp/koldstore/koldstore.control COPY docker-artifacts/koldstore--*.sql /tmp/koldstore/ +COPY docker-artifacts/LICENSE docker-artifacts/NOTICE docker-artifacts/THIRD_PARTY_NOTICES.html /tmp/koldstore/ COPY docker/release-init/ /docker-entrypoint-initdb.d/ COPY docker/release-entrypoint.sh /usr/local/bin/release-entrypoint.sh @@ -82,6 +98,9 @@ RUN set -eux; \ install -m 0644 /tmp/koldstore/koldstore.control "${EXTDIR}/"; \ install -m 0644 /tmp/koldstore/koldstore--*.sql "${EXTDIR}/"; \ install -m 0755 /tmp/koldstore/koldstore.so "${LIBDIR}/koldstore.so"; \ + install -D -m 0644 /tmp/koldstore/LICENSE /usr/share/doc/koldstore/LICENSE; \ + install -m 0644 /tmp/koldstore/NOTICE /usr/share/doc/koldstore/NOTICE; \ + install -m 0644 /tmp/koldstore/THIRD_PARTY_NOTICES.html /usr/share/doc/koldstore/THIRD_PARTY_NOTICES.html; \ rm -rf /tmp/koldstore; \ chmod +x /usr/local/bin/release-entrypoint.sh; \ # Common cold-storage roots for try-it / Windows mapped drives. Entrypoint diff --git a/docker/docker-compose.release.yml b/docker/docker-compose.release.yml new file mode 100644 index 00000000..069f73fc --- /dev/null +++ b/docker/docker-compose.release.yml @@ -0,0 +1,90 @@ +# Run the published pg-koldstore release image (not a source build). +# +# docker pull jamals86/pg-koldstore:latest # PostgreSQL 18 +# docker pull jamals86/pg-koldstore:pg16 # PostgreSQL 16 +# docker compose -f docker/docker-compose.release.yml up -d +# docker compose -f docker/docker-compose.release.yml exec postgres \ +# psql -U postgres -d koldstoredb -c "SELECT koldstore_version();" +# +# Override image / ports: +# KOLDSTORE_IMAGE=jamals86/pg-koldstore:pg16 PG_PORT=5433 \ +# docker compose -f docker/docker-compose.release.yml up -d +# +# Data persistence: +# - koldstore-pgdata → PostgreSQL PGDATA (databases survive image recreate) +# - koldstore-cold → local filesystem cold objects (when not using MinIO) +# - koldstore-minio → MinIO object data +# +# PGDATA is major-version specific. Switching :latest from PG16 → PG18 needs a +# fresh volume (or pg_upgrade); do not reuse a PG16 volume with a PG18 image. +services: + postgres: + image: ${KOLDSTORE_IMAGE:-jamals86/pg-koldstore:latest} + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-koldstoredb} + PGDATA: /var/lib/postgresql/data + # Multi-database managed farms need per-DB slots + background workers. + command: + - postgres + - -c + - max_replication_slots=40 + - -c + - max_wal_senders=40 + - -c + - max_worker_processes=80 + - -c + - max_parallel_workers=16 + ports: + - "${PG_PORT:-5432}:5432" + volumes: + - koldstore-pgdata:/var/lib/postgresql/data + - koldstore-cold:/koldstore-data/cold + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} && psql -U $${POSTGRES_USER} -d $${POSTGRES_DB} -tAc \"SELECT 1 FROM pg_extension WHERE extname = 'koldstore'\" | grep -q 1", + ] + interval: 5s + timeout: 5s + retries: 36 + start_period: 15s + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "${MINIO_PORT:-19000}:9000" + - "${MINIO_CONSOLE_PORT:-19001}:9001" + volumes: + - koldstore-minio:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 3s + retries: 20 + + minio-init: + image: minio/mc:latest + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 minioadmin minioadmin; do + sleep 1; + done; + mc mb --ignore-existing local/koldstore-test; + mc anonymous set none local/koldstore-test; + echo 'minio bucket koldstore-test is ready'; + " + restart: "no" + +volumes: + koldstore-pgdata: + koldstore-cold: + koldstore-minio: diff --git a/docker/fresh-pg18/Dockerfile b/docker/fresh-pg18/Dockerfile index ccf5cfb0..805a9b7b 100644 --- a/docker/fresh-pg18/Dockerfile +++ b/docker/fresh-pg18/Dockerfile @@ -18,6 +18,8 @@ ENV DEBIAN_FRONTEND=noninteractive \ LANGUAGE=en_US:en \ LC_ALL=en_US.UTF-8 +COPY scripts/ci/keys/ACCC4CF8.asc /tmp/ACCC4CF8.asc + RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ @@ -32,8 +34,8 @@ RUN set -eux; \ tzdata; \ echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen; \ locale-gen; \ - curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --batch --yes --dearmor -o /usr/share/keyrings/postgresql.gpg; \ + gpg --batch --yes --dearmor -o /usr/share/keyrings/postgresql.gpg /tmp/ACCC4CF8.asc; \ + rm -f /tmp/ACCC4CF8.asc; \ echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ > /etc/apt/sources.list.d/pgdg.list; \ apt-get update; \ diff --git a/docker/image-description.txt b/docker/image-description.txt index ee8a6090..76e90195 100644 --- a/docker/image-description.txt +++ b/docker/image-description.txt @@ -1,6 +1,6 @@ # pg-koldstore -PostgreSQL 16 with the [KoldStore](https://github.com/kalamdb/koldstore) extension preinstalled. Use this image to try tiered storage without building from source. +PostgreSQL with the [KoldStore](https://github.com/kalamdb/koldstore) extension preinstalled. Use this image to try tiered storage without building from source. **Keep hot data in PostgreSQL. Move historical rows to Parquet. Query one table.** @@ -13,11 +13,13 @@ A built-in database worker auto-flushes managed tables when hot rows exceed `hot ## Quick start ```bash -docker pull jamals86/pg-koldstore:latest +docker pull jamals86/pg-koldstore:latest # PostgreSQL 18 docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 jamals86/pg-koldstore:latest psql postgres://postgres:postgres@127.0.0.1:5432/koldstoredb ``` +PostgreSQL 16 remains available as `jamals86/pg-koldstore:pg16` (or `:-pg16`). + **Windows / Docker Desktop bind mounts:** if you map a host folder to `/koldstore-data`, the image entrypoint creates `/koldstore-data/cold` and makes it writable for the `postgres` user (bind mounts often arrive as root-owned). @@ -68,7 +70,7 @@ SELECT count(*) FROM messages; -- still 1012 via KoldMergeScan | Component | Notes | | --- | --- | -| PostgreSQL | 16 (Ubuntu 24.04 / PGDG) | +| PostgreSQL | 18 by default (`latest` / `pg18`, amd64+arm64); 16 via `pg16` (amd64); optional 17 via `pg17` (amd64) | | `koldstore` | Preinstalled + in `shared_preload_libraries` (required for KoldMergeScan + auto-flush worker); `wal_level=logical` by default | | `pg_cron` | Packaged only; not preloaded. Enable yourself if you want cron-based flush | | Entrypoint | Compatible with official `POSTGRES_*` env vars | @@ -77,10 +79,17 @@ SELECT count(*) FROM messages; -- still 1012 via KoldMergeScan | Tag | Meaning | | --- | --- | -| `latest` | Latest published release image | +| `latest` | Latest published release on **PostgreSQL 18** (amd64 + arm64) | +| `pg18` | Floating tag for the latest PostgreSQL 18 image (amd64 + arm64) | +| `pg16` | Floating tag for the latest PostgreSQL 16 image (**amd64 only**) | +| `pg17` | Floating tag for the latest PostgreSQL 17 image (**amd64 only**, published when enabled in Release) | +| `-pg18` | Specific KoldStore version on PostgreSQL 18 | | `-pg16` | Specific KoldStore version on PostgreSQL 16 | +| `-pg17` | Specific KoldStore version on PostgreSQL 17 | + +Example: `jamals86/pg-koldstore:0.1.12-preview.0-pg18` -Example: `jamals86/pg-koldstore:0.1.5-beta.0-pg16` +> PGDATA is major-version specific. Do not point a PG18 container at a PG16 volume (or the reverse) without `pg_upgrade`. ## Environment diff --git a/docker/sql/release-hot-cold-load.sql b/docker/sql/release-hot-cold-load.sql new file mode 100644 index 00000000..bca07f22 --- /dev/null +++ b/docker/sql/release-hot-cold-load.sql @@ -0,0 +1,176 @@ +-- Release-image load test: manage table, 100k insert, flush, more inserts, +-- hot+cold queries, and full changes_since drain. +\set ON_ERROR_STOP on + +\echo '==> version / preload' +SELECT koldstore_version(); +SELECT koldstore.preload_status(); +SHOW shared_preload_libraries; +SHOW wal_level; + +\echo '==> register filesystem storage' +SELECT koldstore.register_storage( + name => 'release-fs', + storage_type => 'filesystem', + base_path => '/koldstore-data/cold/', + credentials => '{}'::jsonb, + config => '{}'::jsonb +); + +\echo '==> create + manage table (hot_row_limit=10000)' +DROP TABLE IF EXISTS loadtest CASCADE; +CREATE TABLE loadtest ( + id bigint PRIMARY KEY, + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE loadtest SET ( + koldstore_enabled = true, + koldstore_storage = 'release-fs', + koldstore_hot_row_limit = 10000, + koldstore_min_flush_rows = 1, + koldstore_max_rows_per_file = 10000 +); + +\echo '==> insert wave 1: 100000 rows' +INSERT INTO loadtest (id, body) +SELECT g, 'wave1-' || g +FROM generate_series(1, 100000) AS g; + +\echo '==> query after wave 1 (pre-flush)' +SELECT count(*) AS wave1_count FROM loadtest; +SELECT id, body FROM loadtest WHERE id = 1; +SELECT id, body FROM loadtest WHERE id = 100000; +SELECT jsonb_pretty(koldstore.table_status(table_name => 'loadtest'::regclass)) AS status_pre_flush; + +\echo '==> force flush so oldest rows go cold' +SELECT jsonb_pretty(koldstore.flush_table( + table_name => 'loadtest'::regclass, + force => true +)) AS flush_result; + +\echo '==> wait briefly for async mirror / job settle' +SELECT pg_sleep(2); + +\echo '==> status after flush (expect cold segments + pruned hot)' +SELECT jsonb_pretty(koldstore.table_status(table_name => 'loadtest'::regclass)) AS status_post_flush; +SELECT + (koldstore.table_status(table_name => 'loadtest'::regclass)->>'hot_rows')::bigint AS hot_rows, + (koldstore.table_status(table_name => 'loadtest'::regclass)->>'cold_row_count')::bigint AS cold_rows; +SELECT count(*) AS active_cold_segments +FROM koldstore.cold_segments +WHERE table_oid = 'loadtest'::regclass AND status = 'active'; + +\echo '==> query after flush (merge hot+cold)' +SELECT count(*) AS after_flush_count FROM loadtest; +EXPLAIN (COSTS OFF) SELECT count(*) FROM loadtest; +SELECT id, body FROM loadtest WHERE id = 1; -- should be cold +SELECT id, body FROM loadtest WHERE id = 100000; -- may still be hot +EXPLAIN (COSTS OFF) SELECT * FROM loadtest WHERE id = 1; +EXPLAIN (COSTS OFF) SELECT * FROM loadtest WHERE id = 100000; + +\echo '==> insert wave 2: 25000 more rows' +INSERT INTO loadtest (id, body) +SELECT g, 'wave2-' || g +FROM generate_series(100001, 125000) AS g; + +\echo '==> query after wave 2' +SELECT count(*) AS after_wave2_count FROM loadtest; +SELECT id, body FROM loadtest WHERE id = 1; +SELECT id, body FROM loadtest WHERE id = 50000; +SELECT id, body FROM loadtest WHERE id = 125000; +SELECT jsonb_pretty(koldstore.table_status(table_name => 'loadtest'::regclass)) AS status_wave2; + +\echo '==> optional second flush to keep hot bounded' +SELECT jsonb_pretty(koldstore.flush_table( + table_name => 'loadtest'::regclass, + force => true +)) AS flush2_result; +SELECT pg_sleep(2); +SELECT + (koldstore.table_status(table_name => 'loadtest'::regclass)->>'hot_rows')::bigint AS hot_rows, + (koldstore.table_status(table_name => 'loadtest'::regclass)->>'cold_row_count')::bigint AS cold_rows, + (SELECT count(*) FROM loadtest) AS logical_rows; + +\echo '==> changes_since full drain (scan all retained changes)' +CREATE TEMP TABLE cs_drain ( + seq bigint, + op text, + pk jsonb, + deleted boolean, + source text +); + +DO $$ +DECLARE + cur bigint := 0; + batch_count int; + total int := 0; + batch_limit int := 10000; +BEGIN + LOOP + INSERT INTO cs_drain (seq, op, pk, deleted, source) + SELECT seq, op, pk, deleted, source + FROM koldstore.changes_since( + table_name => 'loadtest'::regclass, + since_seq => cur, + limit_rows => batch_limit + ); + GET DIAGNOSTICS batch_count = ROW_COUNT; + EXIT WHEN batch_count = 0; + total := total + batch_count; + SELECT max(seq) INTO cur FROM cs_drain; + RAISE NOTICE 'changes_since page: +% rows (cursor=%, total=%)', batch_count, cur, total; + END LOOP; + RAISE NOTICE 'changes_since drain complete: % rows', total; +END +$$; + +SELECT count(*) AS changes_since_rows FROM cs_drain; +SELECT count(DISTINCT (pk->>'id')) AS distinct_pks FROM cs_drain; +SELECT min(seq) AS min_seq, max(seq) AS max_seq FROM cs_drain; +SELECT source, count(*) FROM cs_drain GROUP BY source ORDER BY source; +SELECT op, count(*) FROM cs_drain GROUP BY op ORDER BY op; + +\echo '==> final assertions' +DO $$ +DECLARE + logical_rows bigint; + cs_rows bigint; + hot_rows bigint; + cold_rows bigint; + segments bigint; +BEGIN + SELECT count(*) INTO logical_rows FROM loadtest; + SELECT count(*) INTO cs_rows FROM cs_drain; + SELECT (koldstore.table_status(table_name => 'loadtest'::regclass)->>'hot_rows')::bigint + INTO hot_rows; + SELECT (koldstore.table_status(table_name => 'loadtest'::regclass)->>'cold_row_count')::bigint + INTO cold_rows; + SELECT count(*) INTO segments + FROM koldstore.cold_segments + WHERE table_oid = 'loadtest'::regclass AND status = 'active'; + + IF logical_rows <> 125000 THEN + RAISE EXCEPTION 'expected 125000 logical rows, got %', logical_rows; + END IF; + IF segments < 1 THEN + RAISE EXCEPTION 'expected active cold segments, got %', segments; + END IF; + IF cold_rows IS NULL OR cold_rows < 1 THEN + RAISE EXCEPTION 'expected cold_row_count > 0, got %', cold_rows; + END IF; + IF hot_rows IS NULL OR hot_rows < 1 THEN + RAISE EXCEPTION 'expected hot_rows > 0, got %', hot_rows; + END IF; + IF cs_rows < 125000 THEN + RAISE EXCEPTION 'changes_since drained %, expected at least 125000', cs_rows; + END IF; + + RAISE NOTICE 'PASS logical=% hot=% cold=% segments=% changes_since=%', + logical_rows, hot_rows, cold_rows, segments, cs_rows; +END +$$; + +\echo '==> release hot/cold load test complete' diff --git a/docs/architecture.md b/docs/architecture.md index 320f02c7..66b9542c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,10 +6,13 @@ access, and cost needs. In KoldStore, the hot tier is the PostgreSQL heap and its native indexes; the cold tier is compressed Parquet on a configured filesystem or object store. -PostgreSQL remains the transaction, locking, and hot-row authority. KoldStore -adds a change-log mirror, cold Parquet segments, and a `KoldMergeScan` custom -scan so SQL, MVCC, permissions, and RLS stay PostgreSQL-owned. Applications -query the original table across both tiers. Tier placement is policy-driven: +PostgreSQL remains the transaction, locking, permission, and MVCC authority for +rows in the hot heap. KoldStore adds a change-log mirror, cold Parquet segments, +and an experimental `KoldMergeScan` custom scan for a supported subset of +`SELECT` through the original relation. PostgreSQL still evaluates the scan's +ordinary quals and RLS policies, but cold rows are not heap tuples: system +columns, row locks, predicate locking, native constraints, and complete +snapshot semantics do not automatically extend to them. Tier placement is policy-driven: today, a hot-row limit selects older mirror sequence values for flush rather than measuring row access frequency automatically. @@ -20,6 +23,7 @@ boundaries at each step: | Workflow | Document | |----------|----------| +| Managed-table lifecycle and DDL identity changes | [managed-table-lifecycle](architecture/managed-table-lifecycle.md) | | Register a table for hot/cold management | [manage-table](architecture/manage-table.md) | | Mirror capture (WAL apply) | [mirror-capture](architecture/mirror-capture.md) | | Move mirror rows to Parquet and prune hot | [flushing-table](architecture/flushing-table.md) | @@ -27,6 +31,11 @@ boundaries at each step: | `INSERT` / `UPDATE` / `DELETE` capture | [dml-table](architecture/dml-table.md) | | Jobs, worker, and automatic flush | [jobs-and-scheduler](architecture/jobs-and-scheduler.md) | +Worker **process lifecycle** (postmaster-forked backends: persistent WAL +applier, ephemeral maintenance, one-shot flush executors, and the 30-second +intervals) is in +[jobs-and-scheduler — Process lifecycle](architecture/jobs-and-scheduler.md#process-lifecycle). + ## Contributor layout See [crate architecture](architecture/crate-architecture.md) for the layered @@ -55,9 +64,14 @@ Design notes for correctness edge cases (proposed or landed): ### Clean-schema mirror (no heap system columns) Managed user tables keep application columns only. Sequence and delete state -live in `koldstore.{table}__cl` and in cold Parquet metadata (`seq`, `deleted`). +live in a schema-qualified mirror named +`koldstore._
__cl` (with a stable hash fallback for long names) +and in cold Parquet metadata (`seq`, `deleted`). Committed primary-key-only WAL is applied to the mirror by a persistent -per-database WAL applier, with an explicit consistency fence for strong reads. +per-database WAL applier, with an explicit committed-change fence. The fence +cannot observe the caller's uncommitted writes and must precede a fixed +`REPEATABLE READ` or `SERIALIZABLE` snapshot that is expected to include the +captured commits. UPDATE uses a direct set-based update for existing mirror keys and a conflict-safe insert-missing fallback for keys already pruned by flush. The applier drains bounded batches on commit latch wakes (with a 30 s watchdog for diff --git a/docs/architecture/crate-architecture.md b/docs/architecture/crate-architecture.md index 487d43e4..f9bcd200 100644 --- a/docs/architecture/crate-architecture.md +++ b/docs/architecture/crate-architecture.md @@ -188,7 +188,9 @@ PostgreSQL postmaster The WAL process holds no transaction, snapshot, apply lock, or slot ownership while sleeping. Commit generations are coalesced; latches are latency hints; the logical slot and `async_mirror_state` remain durable truth. The 30-second -watchdog is recovery insurance, not the normal polling mechanism. +`WaitLatch` timeout is recovery insurance, not the normal polling mechanism. +Fork, lifetime, and interval semantics are in +[jobs-and-scheduler.md](jobs-and-scheduler.md#process-lifecycle). ## Cleanup Policy diff --git a/docs/architecture/dml-table.md b/docs/architecture/dml-table.md index a3718fa3..48a55f9b 100644 --- a/docs/architecture/dml-table.md +++ b/docs/architecture/dml-table.md @@ -16,14 +16,13 @@ accounting, scope enforcement, and how DML state flows into flush and scan. ## Clean-schema model User tables keep application columns only. Each managed table has a latest-state -change-log mirror at `koldstore.{table}__cl`: +change-log mirror at `koldstore._
__cl`: | Column | Type | Meaning | |--------|------|---------| | `` | same as heap | Primary key | | `seq` | `bigint` | Snowflake-style effect id (ordering, flush cutoffs) | | `op` | `smallint` | `1 = INSERT`, `2 = UPDATE`, `3 = DELETE` | -| `commit_lsn` | `pg_lsn` | WAL position sampled at capture (diagnostics only) | The mirror holds **at most one row per PK** — the latest hot-side state for that key. It is not a full event log. Tombstones (`op = 3`) stay until flush prunes @@ -45,7 +44,7 @@ flowchart TD APPLY --> AKIND{"Operation"} AKIND -->|"INSERT / DELETE"| AUPSERT["INSERT ... ON CONFLICT"] AKIND -->|"UPDATE"| AUPDATE["UPDATE existing + upsert missing"] - AUPSERT --> MIR["koldstore.{table}__cl"] + AUPSERT --> MIR["koldstore._
__cl"] AUPDATE --> MIR APPLY --> RC["Row counter deltas"] RC --> MAN["manifest counters"] @@ -72,9 +71,9 @@ succeed because the guard raises only on `IS DISTINCT FROM`. Installed by `koldstore.manage_table` (see [manage-table.md](manage-table.md)): -1. `CREATE TABLE koldstore.{name}__cl` with PK + metadata columns +1. `CREATE TABLE koldstore._
__cl` with PK + metadata columns 2. B-tree on `seq`, plus partial tombstone index `(seq) WHERE op = 3` -3. PK-guard function `koldstore.{name}__cl_pk_guard()` +3. PK-guard function with a bounded, mirror-derived name in `koldstore` 4. One `BEFORE UPDATE OF FOR EACH ROW` guard trigger 5. Counter refresh so manifest hot/mirror counts match live heaps before capture takes over @@ -112,17 +111,22 @@ explicit consistency boundary: | UPDATE | Set-based `UPDATE ... FROM` for existing keys, then conflict-safe insert of only keys missing from the mirror | | DELETE | Set-based `INSERT ... ON CONFLICT DO UPDATE`, setting `op = 3`, so a missing mirror row still becomes a tombstone | -Primary keys cross the pgoutput boundary as protocol text, are grouped in Rust, -bound as parallel `text[]` arrays, and converted back to native PostgreSQL types -inside one typed `unnest`. No per-row JSON recordset is built. Compatible batches -contain at most 8,192 unique keys; a duplicate key, relation change, operation -change, or capacity boundary flushes the current batch. Non-key source columns -are not published or allocated. The mirror remains a metadata index; flush -reads the current row image from the hot heap. +Primary keys cross the pgoutput boundary as protocol text. The applier decodes +each peeked message immediately (it does not retain a raw bytea batch). Segment +order text is peeked before PK cells are taken, because taking replaces those +tuple slots with NULL and `migration_order_by` is often the PK itself. Builtin +int/bool keys parse once into native values. `seq` is an integer and `order_key` +stays bytes. In-batch identity is typed: +a single `bigint`/`int`/`smallint`/`bool` key is an inline HashSet key (no +String, no NUL join). Text and composite keys still own their cells. Compatible +batches contain at most 8,192 unique keys; a duplicate key, relation change, +operation change, or capacity boundary flushes the current batch. Non-key source +columns are not published or allocated. The mirror remains a metadata index; +flush reads the current row image from the hot heap. The applier caches separate upsert and UPDATE plans for each relation. -Pgoutput relation metadata changes invalidate both plans and the cached PK type -names before another batch executes. UPDATE's direct write and insert-missing +Pgoutput relation metadata changes invalidate cached SQL plans, PK type names, +and PK column indexes/OIDs before another batch executes. UPDATE's direct write and insert-missing fallback are one data-modifying CTE, preserving atomicity while avoiding conflict arbitration for the normal existing-row path. @@ -135,18 +139,22 @@ produce new mirror tombstones. When a configured row/time budget ends with WAL still pending, the database worker runs up to four more ticks immediately. The fifth pending result yields through the latch before a new burst, balancing catch-up latency with CPU and -flush-scheduler fairness. Capture does not provide transparent read-your-writes; -strong reads still use the fence. Full operational semantics are in +flush-scheduler fairness. Capture does not provide transparent read-your-writes. +The fence covers changes that committed before its WAL boundary; it cannot +decode the caller's uncommitted writes. In `REPEATABLE READ` or `SERIALIZABLE`, +a snapshot acquired before the fence also cannot see later-applied mirror state. +Call the fence before opening the transaction/snapshot that requires those +commits. Full operational semantics are in [mirror-capture.md](mirror-capture.md). ### Encoding at mirror boundary | Field | Source | Type | |-------|--------|------| -| PK values | pgoutput text → typed unnest | Native PG column types | +| PK values | pgoutput text → parse-once native arrays | Native PG column types | | `seq` | WAL applier snowflake allocation | `i64` above durable watermark | +| `order_key` | pgoutput text → sort-key bytes | `bytea` when configured | | `op` | `MirrorOperation::code()` | `smallint` 1/2/3 | -| `commit_lsn` | apply-path sample | `pg_lsn` (diagnostics) | --- @@ -203,7 +211,8 @@ Used by flush stats resolution and operator diagnostics. Mid-transaction reads of `manifest.mirror_row_count` do not include pending backend deltas until pre-commit flush (pg_tests that assert counters call `flush_pending_deltas` explicitly). Flush selection folds `row_counter_cache::pending_deltas` into the -O(1) mirror pending count so an in-transaction async fence cannot miss rows. +O(1) mirror pending count. This counter accounting does not make uncommitted +source changes visible to logical decoding or to `KoldMergeScan`. --- @@ -246,8 +255,11 @@ Session scope is set with: SET koldstore.user_id = ''; ``` -`koldstore.user_id` is a GUC. Applications must set it before scoped DML and -reads. +`koldstore.user_id` is a user-settable GUC. Applications must set it before +scoped DML and reads, but it is not proof of identity. A trusted connection +layer must bind it to an authenticated principal. The generated policy is +permissive; PostgreSQL OR-combines it with any other permissive policy on the +table, so additional policies can broaden access. `hooks/executor.rs::enforce_dml_scope` is a pure helper used by unit/shell tests and planning code. It is **not** registered as a live executor hook; diff --git a/docs/architecture/flushing-table.md b/docs/architecture/flushing-table.md index 1ef83e3e..5ecb29b0 100644 --- a/docs/architecture/flushing-table.md +++ b/docs/architecture/flushing-table.md @@ -57,9 +57,20 @@ background WAL applier keeps writing `__cl` while that work runs, so tables. The slot/apply lock is acquired only inside finalize via try-lock + bounded -retry (`with_slot_lock_retry`): pre-lock catch-up, then the prune fence. That -short exclusive window is required so prune cannot race concurrent apply on the -same mirror keys. See [mirror-capture.md](mirror-capture.md) and +retry (`with_slot_lock_retry`, ~10s). That lock serializes **mirror apply vs +flush prune**, not heap DML: user `INSERT`/`UPDATE`/`DELETE`/`SELECT` on the +source table do not take it. Encode and object upload hold neither the slot +lock nor a table lock, so concurrent sessions keep committing under MVCC. + +The WAL applier also try-locks. If finalize already holds the slot lock, the +applier yields and retries instead of blocking heap-unrelated apply behind a +re-taken lock. Per-tick GUC budgets are unchanged (`0` = drain the current +fence in one apply transaction). + +The only source-table lock on the flush path is a **short** `SHARE ROW +EXCLUSIVE` during the prune fence after Parquet is already durable — in-flight +writers finish, new writers wait for that fence, ordinary `SELECT` continues. +See [mirror-capture.md](mirror-capture.md) and [async-flush-prune-race](../cases/async-flush-prune-race.md). Manual vs automatic: `auto_flush => false` disables scheduler-driven enqueue; @@ -194,7 +205,7 @@ If `selection.stats.row_count == 0`: ```sql SELECT , mirror."seq", mirror."op", (mirror."op" = 3) AS deleted -FROM koldstore.{table}__cl AS mirror +FROM koldstore._
__cl AS mirror LEFT JOIN ONLY {schema}.{table} AS hot ON WHERE mirror."seq" <= $1 -- max_seq cutoff AND mirror."seq" > $2 -- keyset lower bound @@ -329,7 +340,7 @@ not JSON cleanup**. Runs only after pending segments are activated. ```sql WITH removed_mirror AS ( - DELETE FROM koldstore.{table}__cl AS mirror + DELETE FROM koldstore._
__cl AS mirror WHERE mirror."seq" <= $1 [AND mirror."op" = …] RETURNING , seq, op ), diff --git a/docs/architecture/jobs-and-scheduler.md b/docs/architecture/jobs-and-scheduler.md index 77d00f02..01c2cbbc 100644 --- a/docs/architecture/jobs-and-scheduler.md +++ b/docs/architecture/jobs-and-scheduler.md @@ -17,29 +17,74 @@ transactions can run flush in the calling backend. flowchart TD supervisor["Cluster supervisor"] --> wal["Persistent WAL applier / DB"] supervisor --> maint["Ephemeral maintenance / DB"] - supervisor --> spawn["Spawn flush executors\nupto max_parallel_flush_jobs"] + supervisor --> exec["One-shot flush executor"] maint --> cadence{"Flush check due?"} cadence -->|yes| candidate["Find first eligible table"] candidate --> enqueue["Enqueue flush job"] - enqueue --> spawn + walApply["WAL apply counter bump"] --> enqueue client["flush_table()"] --> enqueue - client --> spawn - spawn --> exec["One-shot flush executor"] - exec --> jobs["Update koldstore.jobs"] + enqueue --> dirty["COMMIT publishes flush generation"] + dirty --> supervisor + exec --> jobs["Claim koldstore.jobs"] ``` -The static cluster supervisor discovers KoldStore-active databases and keeps -required services alive. One persistent WAL applier runs per active database -(see [mirror-capture.md](mirror-capture.md)). Maintenance workers are -ephemeral: they reconcile recovery, evaluate automatic flush eligibility, -reconcile the flush queue, recover orphan jobs, wait a short 200 ms -burst-coalescing grace, then exit when caught up. - -`manage_table`, explicit consistency fences, and the supervisor also ensure -lifecycle when necessary. WAL appliers use `BGW_NEVER_RESTART` so intentional -slot drop leaves them stopped until the supervisor re-registers a still-required -service. A registration backoff applies when `max_worker_processes` is -exhausted. +Only the supervisor registers child workers. See [Process lifecycle](#process-lifecycle) +for fork, lifetime, and the 30-second intervals. WAL apply details are in +[mirror-capture.md](mirror-capture.md). A registration backoff applies when +`max_worker_processes` is exhausted. + +## Process lifecycle + +These are PostgreSQL **background workers**. The postmaster forks a full backend +(own PID, `pg_stat_activity.backend_type`). They are not threads inside another +process. The supervisor is registered at `shared_preload_libraries` load. +Children are dynamic (`load_dynamic`) with `BGW_NEVER_RESTART`; the supervisor +re-registers a still-required service. + +| Process | `backend_type` | Lifetime | Work | +| --- | --- | --- | --- | +| Cluster supervisor | `koldstore supervisor` | Persistent (postmaster restart 1 s). Connects to `postgres`. | One per cluster. Dispatches from **shared-memory** generations and deadlines. Does not poll `koldstore.jobs` and does not decode WAL. Cluster cap of 8 concurrent flush executors. | +| WAL applier | `koldstore wal applier ` | Persistent while the database owns a KoldStore logical slot | One per active database. Sleeps on `WaitLatch`; managed-commit `SetLatch` is the normal wake. Logical-decodes pgoutput from the database slot into `__cl`, then sleeps. Soft apply errors back off in-process (100 ms … 5 s). No Parquet or object-store I/O. | +| Maintenance | `koldstore maintenance ` | Ephemeral: 200 ms idle grace, then exit. At most one per database. | Orphan-job reclaim, auto-flush reconciliation, flush-queue hints. Does not encode Parquet. | +| Flush executor | `koldstore flush executor ` | One-shot: claim one job, run it, exit | Started only when the supervisor sees a dirty flush-queue generation **and** capacity remains (`koldstore.max_parallel_flush_jobs` per database). | + +Client backends (`flush_table`, `enqueue_flush_job`) never call +`RegisterBackgroundWorker`. They write a durable job and a transaction-local +dirty bit; COMMIT publishes a generation; the supervisor registers the child. + +### How a flush job becomes a process + +1. A `pending` row lands in `koldstore.jobs` and the backend marks the flush + queue dirty. Sources: `flush_table`, `enqueue_flush_job`, WAL-apply policy + evaluation on a counter bump (`schedule_policy_after_counter`), or a + maintenance scheduler tick. +2. COMMIT publishes the generation. The supervisor does **not** discover work by + querying `jobs`. +3. If running/starting executors are below the per-database limit and the + cluster cap of 8, the supervisor registers a dynamic worker and the + postmaster forks it. +4. That process pages pending jobs (16 at a time), try-locks one table, claims + the job, runs [flushing-table.md](flushing-table.md), then exits. +5. No claimable job → the process exits without Parquet work. Locked candidates + schedule a 200 ms retry deadline rather than spinning. + +No pending flush work means **no flush executor is forked**. + +### What the 30-second intervals are + +| Interval | Owner | Meaning | +| --- | --- | --- | +| `koldstore.flush_check_interval_seconds` (default 30) | Maintenance | When this deadline fires, the supervisor starts one ephemeral maintenance backend. That process evaluates auto-flush candidates (up to 64 tables; enqueues at most one job). It is **not** “fork a flush executor every 30 s.” If nothing is due, maintenance exits and no executor is created. | +| WAL `WaitLatch` timeout (30 s, `WAL_APPLIER_WATCHDOG` in `wal.rs`) | WAL applier | Safety recovery if a commit latch is missed (including two-phase commit). **Not** the normal apply poll. The applier holds no transaction while waiting. Managed commits `SetLatch` immediately. | +| Supervisor safety reconcile (30 s) | Supervisor | Reconciles slot/worker liveness when the cluster has KoldStore slots. | + +The usual auto-flush enqueue does **not** wait for the maintenance interval: +after a WAL apply counter bump, the apply transaction evaluates that table and +may enqueue immediately. Maintenance is the reconciliation / RowLimit cadence +for tables that were not just applied, plus orphan recovery. + +Idle WAL apply is latch-driven logical decoding of the database slot, not a +continuous tail of WAL files. See [mirror-capture.md](mirror-capture.md). ## `koldstore.jobs` @@ -62,8 +107,8 @@ Useful SQL entry points: | Entry point | Purpose | | --- | --- | -| `koldstore.flush_table(table)` | Enqueue or reuse the active flush job, spawn an executor when needed, return a jsonb status object (`job_id`, `status`, `error`, …). | -| `koldstore.enqueue_flush_job(table)` | Same durable enqueue/lookup without spawning executors. | +| `koldstore.flush_table(table)` | Enqueue or reuse the active flush job; COMMIT publishes a flush-queue generation so the supervisor can register a one-shot executor. Returns jsonb (`job_id`, `status`, `error`, …). | +| `koldstore.enqueue_flush_job(table)` | Same durable enqueue/lookup. Does not register a worker from this backend; COMMIT still publishes the queue generation. | | `koldstore.list_jobs(statuses, job_types, table)` | Read job status and progress as JSON. | | `koldstore.cancel_job(id)` | Request cooperative cancellation of one active job. | | `koldstore.cancel_table_jobs(table)` | Cancel pending work and request cancellation of running work for a table. | @@ -75,22 +120,26 @@ it can acquire that table's job lock, which proves no live owner holds it. ## Maintenance and WAL loops +See [Process lifecycle](#process-lifecycle) for fork, PID, and interval +semantics. This section is the wake contract. + Managed commits advance a shared WAL generation and set the persistent WAL applier latch (with the cluster supervisor as lifecycle fallback). Concurrent commits coalesce into one bounded WAL drain. Soft SPI/apply errors stay in the WAL process with bounded exponential backoff rather than permanently ending the applier; hard process death is recovered by the supervisor even when the mirror -is already caught up. A 30-second watchdog recovers missed in-memory hints -without opening an idle apply transaction. +is already caught up. The 30-second `WaitLatch` timeout recovers missed +in-memory hints without opening an idle apply transaction. -Flush scheduling is independent of the apply wake. Ephemeral maintenance runs -only when recovery or schedule work is due, evaluates at most one table, and -enqueues at most one auto-flush job per check. It may then ask the supervisor to -spawn multiple executors for already pending work up to -`koldstore.max_parallel_flush_jobs`. +Flush scheduling is independent of the apply wake. WAL apply may enqueue a flush +job in the same transaction that bumped a table's mirror counters. Ephemeral +maintenance runs when recovery or a scheduled deadline is due, scans auto-flush +candidates, and enqueues at most one auto-flush job per tick. Remaining due +tables publish another maintenance generation. Pending jobs wake the supervisor, +which registers executors up to `koldstore.max_parallel_flush_jobs`. Auto-flush eligibility is **not** driven by PostgreSQL autovacuum. It uses -KoldStore mirror / hot-row policy on that check cadence. See +KoldStore mirror / hot-row policy. See [operations/scheduling.md](../operations/scheduling.md). ## Automatic flush selection @@ -98,8 +147,9 @@ KoldStore mirror / hot-row policy on that check cadence. See A table is eligible only when it is active, has an enabled flush policy, and has not opted out with `auto_flush = false`. Candidate selection excludes a table with a running flush and delays a table for 60 seconds after a failed -flush. Candidates are ordered by newest managed table first; selection stops at -the first one whose policy is due. +flush. Candidates are ordered by newest managed table first. A tick scans up +to 64 tables and enqueues at most one due job; remaining due tables publish +another maintenance generation. - `row_limit` policies use the manifest mirror-row counter plus pending local counter deltas and flush only the policy-selected excess. @@ -117,10 +167,10 @@ started by the cluster supervisor. | Setting | Effect | | --- | --- | -| `koldstore.async_apply_watchdog_interval_ms` | Safety recovery cadence for a missed commit wakeup. | +| `koldstore.async_apply_watchdog_interval_ms` | Registered GUC (default `30000`). Managed commits `SetLatch` immediately. The applier's idle wait is currently the hardcoded 30 s `WAL_APPLIER_WATCHDOG` in `wal.rs`, not this GUC. | | `koldstore.async_apply_max_rows_per_tick` / `...max_ms_per_tick` | Bound one apply transaction. | -| `koldstore.flush_check_interval_seconds` | Automatic-flush evaluation cadence. | -| `koldstore.max_parallel_flush_jobs` | Cap on concurrent one-shot flush executors per database. | +| `koldstore.flush_check_interval_seconds` | Cadence for ephemeral maintenance auto-flush reconciliation (default 30). Does not by itself start a flush executor. | +| `koldstore.max_parallel_flush_jobs` | Cap on concurrent one-shot flush executors per database (cluster cap 8). | | `koldstore.flush_execution` | `queue` (default) or `inline` (SPI tests only). | | `koldstore.job_retention_days` | Days to retain terminal jobs before purge (`0` disables). | | `auto_flush` table option | Enables or opts a table out of background flushes. | diff --git a/docs/architecture/manage-table.md b/docs/architecture/manage-table.md index b7a80612..ee742e5d 100644 --- a/docs/architecture/manage-table.md +++ b/docs/architecture/manage-table.md @@ -3,7 +3,7 @@ This document describes what actually happens when you call `koldstore.manage_table`. It is written against the current clean-schema implementation: user heap tables keep application columns only; KoldStore state -lives in a change-log mirror (`koldstore.{table}__cl`), catalog tables, and +lives in a change-log mirror (`koldstore._
__cl`), catalog tables, and (once flushed) cold Parquet segments. **SQL entrypoint:** `koldstore.manage_table(table_name regclass, storage text, …) → uuid` @@ -136,7 +136,7 @@ rejected unless `allow_fk_hot_only = true`. | Artifact | Name pattern | Contents | |----------|--------------|----------| -| Mirror table | `koldstore.{source_table}__cl` | PK cols + `seq bigint`, `op smallint`, `commit_lsn pg_lsn` (+ optional `order_key`) | +| Mirror table | `koldstore.___cl` | PK cols + `seq bigint`, `op smallint` (+ optional encoded `order_key`) | | Seq index | `{mirror}_seq_idx` | `("seq")` | | Tombstone index | `{mirror}_tombstone_seq_idx` | `("seq") WHERE op = 3` | | PK guard | on user heap | BEFORE UPDATE OF primary-key columns FOR EACH ROW | @@ -148,7 +148,24 @@ removal must be justified by write, catch-up, and flush benchmarks. The user heap is **not** altered. No `_seq`, `_commit_seq`, or `_deleted` columns are added (clean-schema contract). -### 2.3 Existing-table ordering (populated heap only) +### 2.3 Mirror identity and collision protection + +The source schema is part of every new mirror identity: for example, +`db1.messages` uses `koldstore.db1_messages__cl`, while `db2.messages` uses +`koldstore.db2_messages__cl`. This prevents the two sources from sharing +latest-state rows or generated PK-guard artifacts. + +PostgreSQL identifiers are limited to 63 bytes. When the readable generated +name would exceed that limit, KoldStore keeps its suffix and uses a stable hash +for the omitted portion. The same bounded-name rule applies to generated mirror +indexes and PK-guard trigger/function names. + +Before creating a mirror, `manage_table` verifies that no other **active** +managed table owns the proposed mirror relation. This is a fail-closed defence +for a generated-name collision or a corrupted legacy catalog; `CREATE TABLE IF +NOT EXISTS` is never relied on as a sharing mechanism. + +### 2.4 Existing-table ordering (populated heap only) `plan_existing_table_migration` chooses backfill order: @@ -165,7 +182,7 @@ Batch size defaults to `10_000` (`DEFAULT_BACKFILL_BATCH_ROWS`). When `EXISTS (SELECT 1 FROM ONLY table LIMIT 1)` is false: 1. **Create mirror objects** — `mirror_plan.create_statements()` via SPI: - - `CREATE TABLE IF NOT EXISTS koldstore.{name}__cl` + - `CREATE TABLE IF NOT EXISTS koldstore._
__cl` - Indexes - PK-update guard only (no DML capture triggers) @@ -212,7 +229,7 @@ When the heap already has rows: 5. **Inline mirror backfill** — `run_existing_table_mirror_initialization_inline`: - Loops `plan_mirror_initialization_batch` until no candidates - Each batch: scan hot rows missing mirror rows, `INSERT … ON CONFLICT DO NOTHING` - - `op = 1`, `seq` from WAL-apply-safe snowflake allocation, `commit_lsn` sampled + - `op = 1`, `seq` from WAL-apply-safe snowflake allocation - `ORDER BY ASC, ctid ASC`, `FOR KEY SHARE SKIP LOCKED` 6. **Catch-up** — apply committed WAL above the seq floor until caught up @@ -236,6 +253,18 @@ row is durable audit/progress; there is no separate worker claim loop in Publication membership is established before backfill so concurrent DML cannot escape capture. See [mirror-capture.md](mirror-capture.md). +## DDL after management + +The source relation keeps its PostgreSQL OID when it is renamed or moved. Its +mirror name, however, encodes the source's schema-qualified identity. The DDL +hook therefore rehomes the mirror table, generated indexes, and PK-guard +artifacts after a table or schema identity change. The catalog continues to +refer to the same relation and mirror OIDs; caches and prepared plans are +invalidated after the rehome. + +See [managed-table lifecycle and DDL](managed-table-lifecycle.md) for the +complete table/schema/database rename matrix and legacy shared-mirror behavior. + --- ## Phase 5 — Manifest row counter initialization diff --git a/docs/architecture/managed-table-lifecycle.md b/docs/architecture/managed-table-lifecycle.md new file mode 100644 index 00000000..63ee6f30 --- /dev/null +++ b/docs/architecture/managed-table-lifecycle.md @@ -0,0 +1,122 @@ +# Managed-Table Lifecycle and DDL + +This document describes the user-visible lifecycle of a managed table: what +`koldstore.manage_table` installs, how hot/mirror/cold reads are merged, and +which PostgreSQL identity changes rehome generated artifacts. + +For the detailed execution paths, see [manage-table](manage-table.md), +[mirror-capture](mirror-capture.md), [flushing-table](flushing-table.md), and +[scanning-table](scanning-table.md). + +## Lifecycle at a glance + +```mermaid +flowchart LR + Heap["Ordinary PostgreSQL heap"] -->|"manage_table"| Managed["Managed source relation"] + Managed --> Mirror["koldstore._
__cl"] + Managed --> Wal["PK-only committed WAL"] + Wal --> Mirror + Mirror -->|"flush_table / policy"| Cold["Published Parquet segments"] + Heap --> Read["KoldMergeScan when cold can contribute"] + Mirror --> Read + Cold --> Read +``` + +The heap remains the application table and PostgreSQL remains responsible for +transactions, locking, permissions, RLS, and native hot indexes. KoldStore adds +metadata and cold storage around that heap; it does not replace it with a new +table type. + +## What `manage_table` does + +For a source such as `db1.messages`, management creates or configures: + +| Item | Result | +| --- | --- | +| Mirror | `koldstore.db1_messages__cl`, containing primary-key columns plus sequence and operation metadata | +| Mirror indexes | Sequence and tombstone-sequence indexes for flush and change reads | +| PK guard | A source-table trigger that rejects a real primary-key change, preserving the merge identity | +| Catalog state | `koldstore.schemas`, `koldstore.jobs`, and initial manifest counters | +| WAL capture | Source PK publication membership and the database-scoped logical WAL applier | +| Cold storage | Nothing immediately; Parquet segments are created only by a later flush | + +For an empty source table, the mirror is immediately active. For a populated +source table, KoldStore publishes the relation, backfills the mirror, catches up +committed WAL, and only then marks the table active. This closes the gap between +the snapshot backfill and concurrent writes. + +The generated name includes the **PostgreSQL schema**, not merely the relation +name. Thus `db1.messages` and `db2.messages` get independent mirrors. Very long +generated artifact names use a deterministic hash while retaining their semantic +suffixes, avoiding PostgreSQL's implicit identifier truncation as a source of +shared artifact identities. + +## How reads merge hot and cold state + +Before the first published segment, PostgreSQL reads the heap with its native +plan. Once cold storage can satisfy part of a query, `KoldMergeScan` combines +the hot heap, latest-state mirror, and cold Parquet segments by primary key. + +1. The native hot child supplies current heap rows and preserves PostgreSQL + access-path behavior. +2. The mirror identifies newer inserts, updates, and deletes. It masks an older + cold row with the same primary key; a tombstone suppresses that cold row. +3. Unmasked cold rows are eligible to appear. The resolver emits exactly one + winner per primary key. + +The planner retains a native heap plan only when the cold side is proven empty +or unable to match. It must use the merge path whenever cold could contribute; +otherwise a query could omit valid cold rows. + +## Source DDL and generated-artifact behavior + +The source table's PostgreSQL OID is stable across rename and schema moves, and +KoldStore rehomes generated mirror artifacts by the new name. The default cold +object prefix, however, is derived from the current database/schema/table name. +Once a table has published cold segments, renaming the table or moving/renaming +its schema can therefore make existing objects unreachable. These operations +are not supported until storage identity is detached from mutable names +([#64](https://github.com/kalamdb/koldstore/issues/64)). + +| User operation | Source identity after DDL | Mirror effect | +| --- | --- | --- | +| `ALTER TABLE db1.messages RENAME TO events` | `db1.events` | Mirror is rehomed, but published cold paths still use the old name; unsupported after cold publish | +| `ALTER TABLE db1.messages SET SCHEMA db2` | `db2.messages` | Mirror is rehomed, but published cold paths still use the old schema; unsupported after cold publish | +| `ALTER SCHEMA db1 RENAME TO db2` | Every managed table moves from `db1.*` to `db2.*` | Mirrors are rehomed; tables with published cold data are unsupported | +| `DROP TABLE db1.messages` | Relation gone | Catalog deactivated; cold objects deleted inline; mirror dropped | +| `DROP SCHEMA db1 CASCADE` | Every managed table in `db1` gone | Same per-table cleanup before PostgreSQL removes the heaps | +| `ALTER DATABASE old RENAME TO new` | Same relations in the same database OID | No mirror rename is needed | + +The final row is intentionally different: KoldStore catalogs, the logical slot, +and generated mirrors are database-local. A PostgreSQL database rename keeps +the database OID and its contained schemas/relations, so no source identity used +by a mirror changes. In the issue terminology, names such as `db1.messages` +refer to schemas, not separate PostgreSQL databases. + +The current `DROP TABLE` cleanup performs object-store deletion before the +PostgreSQL DDL transaction commits. Object deletion is not transactional, so a +later rollback can leave catalog state referring to missing files. Durable +post-commit garbage collection is tracked in +[#100](https://github.com/kalamdb/koldstore/issues/100). The `drop_cold` +argument to `unmanage_table` is also not executed by the current implementation; +do not rely on it for retention or deletion. + +Other `ALTER TABLE` changes run a post-DDL schema refresh. Because PostgreSQL +has already applied the DDL when that refresh runs, an unsupported refresh can +warn without rolling back the original change. Treat schema evolution as a +preview surface, and verify hot+cold results after every change. + +## Legacy shared mirrors + +Older installations could create `koldstore.messages__cl` for multiple source +schemas. KoldStore now takes two protective measures: + +1. `manage_table` refuses a proposed mirror that another active source already + owns. +2. Unmanage, source-drop cleanup, and mirror rehoming refuse to remove or move + a mirror still referenced by another active source. + +KoldStore does not automatically split a legacy shared mirror: its rows cannot +be safely attributed to their original source after a collision. The failure is +intentional—repair the affected tables explicitly rather than risking data loss +or a dangling catalog reference. diff --git a/docs/architecture/mirror-capture.md b/docs/architecture/mirror-capture.md index 414c1b90..4ae78011 100644 --- a/docs/architecture/mirror-capture.md +++ b/docs/architecture/mirror-capture.md @@ -45,6 +45,10 @@ koldstore supervisor persistent, one per cluster └── koldstore flush executor bounded ephemeral pool ``` +Each line is a postmaster-forked PostgreSQL backend (own PID), not a thread. +Lifetimes, dispatch, and the 30-second intervals are in +[jobs and scheduler — Process lifecycle](jobs-and-scheduler.md#process-lifecycle). + WAL application is a latency-sensitive service. Scheduled maintenance, policy reconciliation, Parquet encoding, and object-store I/O are jobs and remain outside the always-on applier. @@ -108,6 +112,11 @@ insert-missing fallback. A batch, its row-counter delta, policy hints, and its durable `applied_lsn` checkpoint commit together. The slot advances to a checkpoint only on a later pass, making replay after a crash safe. +Each apply tick holds the database slot lock only for that transaction. That +lock is not a heap lock: application DML on the source table does not take it. +The applier try-locks and yields when flush finalize holds the slot lock, so +finalize can prune without the applier immediately re-taking the lock. + Sequence allocation is always above the durable high watermark and any flush prune floor. This prevents a post-restart or concurrent flush apply from reusing a sequence range that was already published to cold storage. @@ -155,7 +164,8 @@ requirement so a surviving slot is never abandoned. there only after WAL apply has written `__cl`. - Call `wait_for_async_mirror()` before a read that requires an exact catch-up boundary. Background apply is normally latch-driven and sub-second; the fence - is the strong consistency API. + covers committed WAL through a captured boundary. It cannot observe the + caller's uncommitted changes or advance a snapshot acquired before the call. - Automatic flush is optional (`auto_flush`). Latency-sensitive change-feed consumers often manage tables with `auto_flush => false` and call `flush_table` deliberately so finalize windows are predictable. Auto-flush diff --git a/docs/architecture/scanning-table.md b/docs/architecture/scanning-table.md index a9a030cb..21b4f826 100644 --- a/docs/architecture/scanning-table.md +++ b/docs/architecture/scanning-table.md @@ -1,6 +1,6 @@ # Scanning Managed Tables -Managed tables remain ordinary PostgreSQL heaps. A read uses PostgreSQL's native +Managed tables retain an ordinary PostgreSQL heap for hot rows. A read uses PostgreSQL's native plan when published cold storage cannot contribute; otherwise the KoldMergeScan custom scan combines hot heap rows, cold Parquet rows, and the unflushed mirror overlay. This document describes the current planner and @@ -21,8 +21,25 @@ runs for each base relation in a SELECT, so joins can mix managed and unmanaged tables. Unmanaged relations, relations in a database without the extension catalog, and extension-internal SPI queries retain PostgreSQL's normal planning. +The examples below assume `public.messages` has already been managed and at +least one `flush_table` has published cold segments. Use this form to inspect +both the selected plan and the executor path: + +```sql +EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) +SELECT id, body +FROM public.messages +WHERE id = 42; +``` + +Plain `EXPLAIN` reports the planned strategy and potential sources. `EXPLAIN +ANALYZE` also reports **Actual Access**, the emit path, cold-open counts, mirror +overlay counters, and pruning. Exact node text is PostgreSQL-version and +statistics dependent; the correctness contract does not depend on whether the +native hot child is an Index, Bitmap, or Seq Scan. + If a table is managed and cold storage might contribute, the hook removes both -ordinary final and partial paths and installs a single KoldMergeScan path. +ordinary final and partial paths and installs a KoldMergeScan path portfolio. Clearing partial_pathlist matters: otherwise PostgreSQL can build a Gather or Gather Merge over a leftover hot-only path and return an incomplete ordered or limited result. @@ -64,6 +81,45 @@ before newly published cold rows become visible. Missing catalog data, unsupported types, mutable predicates, incomplete bounds, or catalog errors remain conservative and use KoldMergeScan. +### Planner selection matrix + +The hook runs only for a base relation in a `SELECT`. Every earlier exit keeps +PostgreSQL's original paths; no KoldStore source is read in that case. + +| Condition at planning time | Example | Plan selected | Why | +| --- | --- | --- | --- | +| Not a `SELECT`, not a relation RTE, extension-internal SPI, catalog unavailable, or unmanaged table | `UPDATE public.messages ...`; `SELECT * FROM public.audit_log` | Native PostgreSQL | The hook is deliberately out of scope or the relation has no managed cold state. | +| Managed table with no published segments | Immediately after managing an empty table | Native PostgreSQL | The hot heap is complete. | +| Constant predicate proves every cold segment is outside the range | `WHERE id >= 1000000` when cold `id` max is lower | Native PostgreSQL | Catalog bounds prove cold cannot contribute. | +| Managed table with a cold-capable predicate | `WHERE id BETWEEN 1 AND 100` | `KoldMergeScan` portfolio | Cold might contain visible rows. | +| Parameter or incomplete/unknown bounds | `PREPARE q(bigint) AS SELECT * FROM public.messages WHERE id >= $1` | `KoldMergeScan` portfolio | The generic plan cannot assume a future parameter makes cold irrelevant. | +| Catalog/pruning uncertainty | Unsupported or incomplete metadata | `KoldMergeScan` or an error at execution | Correctness wins over a potentially incomplete heap-only result. | + +Publication invalidates PostgreSQL relation caches. A prepared statement that +had a native plan before the first flush is therefore replanned before it can +observe cold rows. Likewise, a catalog-bound expansion invalidates a plan that +previously proved cold empty. + +## Strategy portfolio and query shapes + +When cold may contribute, KoldStore removes bare final and partial heap paths +and installs complete logical-table paths. PostgreSQL chooses among that +portfolio using its normal cost and pathkey rules. The strategy describes the +planned shape; the executor may still take a faster runtime path later. + +| Strategy | Selected for | Query examples | Execution contract | +| --- | --- | --- | --- | +| `ExactPrimaryKey` | Equality predicates cover **every** PK column with a constant or external parameter | `WHERE id = 42`; `WHERE tenant_id = 'a' AND id = 42` for a composite PK | Probe a visible hot point hit before opening Parquet. A miss consults the mirror and cold candidates. | +| `UnorderedHotFirst` | No usable `ORDER BY` path; especially valuable with a parent `LIMIT` | `SELECT * FROM public.messages LIMIT 20`; `WHERE id >= 1 LIMIT 20` | Emit visible hot rows first and defer mirror/cold work until hot is exhausted. Without `ORDER BY`, any valid row order is permitted. | +| `OrderedProgressive` | The native path's leading key is the first PK column or the configured immutable segment-order column | `ORDER BY id DESC LIMIT 20`; `ORDER BY created_at ASC LIMIT 20` when `created_at` is the segment-order column | Compare the hot frontier with catalogued cold bounds; retain supported pathkeys so PostgreSQL can avoid an external `Sort`. | +| `GeneralMerge` | Conservative full logical merge when a General-Merge strategy is carried into execution | No normal SQL shape is guaranteed to force this tag; verify `Strategy: General Merge` in `EXPLAIN ANALYZE` | Read hot candidates in SPI JSON keyset pages, resolve them with mirror/cold rows, and let PostgreSQL apply an outer `Sort` if required. | + +For an `ORDER BY` that does not match a supported ordered path, the current +portfolio normally advertises an unordered logical path and PostgreSQL adds a +`Sort` above it. The result remains correct; it simply cannot use the +ordered-progressive early-stop optimization. Do not infer a strategy from SQL +alone—inspect `Strategy` in `EXPLAIN ANALYZE`. + ## KoldMergeScan shape KoldMergeScan @@ -72,8 +128,26 @@ remain conservative and use KoldMergeScan. plus latest-state __cl mirror overlay The hot child preserves PostgreSQL's index, permission, locking, and RLS -behavior. KoldStore is the coordinator; it does not replace the heap with a -custom table access method or a view rewrite. +behavior for heap tuples. Cold rows are materialized by the custom scan, not by +the heap access method, so heap system columns, row locks, SSI predicate locks, +and native constraint checks do not apply to them. KoldStore is the coordinator; +it does not replace the heap with a custom table access method or a view rewrite. + +### What is being merged + +`KoldMergeScan` is a correctness merge, not a union of independently visible +tables. It resolves each primary-key identity across these sources before it +emits a row: + +| Source | Role | Can override an older cold row? | +| --- | --- | --- | +| Hot heap | Current PostgreSQL row image and native access path | Yes | +| `koldstore._
__cl` | Latest-state metadata: sequence and tombstone overlay | Yes; updates/inserts mask cold, deletes suppress it | +| Cold Parquet segments | Published older row images | Only when no newer hot/mirror state masks the key | + +The exact-winner resolver retains primary-key identities, not complete row +images. This is why an ordinary heap-only plan is permitted only when the +planner or executor has proven that cold cannot contribute. At plan time the cheapest native hot path becomes the custom scan's child. The custom scan is not parallel-safe, so it owns the final scan path whenever cold @@ -151,6 +225,27 @@ under the catalog node to show that catalog prune decides which files open. Mirror tombstone counters remain under the text `Mirror Scan` group; they are not duplicated as a visual plan node. +### Cold-pruning and opening cases + +Pruning is progressive. It begins with catalog-only checks and opens Parquet +only for the remaining candidates. Any unavailable or incomplete proof is +conservative: it expands the candidate set or raises an error, never returns a +heap-only partial result. + +| Stage | Applies to | Example | Effect | +| --- | --- | --- | --- | +| Manifest segment count | Every managed `SELECT` | First query after `manage_table`, before any flush | Zero published segments keeps the native heap plan. | +| Aggregate Sort-Key bounds | Constants at planning; external parameters at execution | `WHERE id > 1000000` | Can prove cold empty and keep/delegate to the native child. | +| Segment-index bounds | Cold-capable predicates on PK, catalog-indexed, scope, or segment-order columns | `WHERE created_at >= '2026-01-01'` | Selects only segment objects whose min/max bounds overlap. | +| Packed row-group bounds | A selected segment with finer-grained index metadata | Same range inside a wide segment | Skips noncompetitive row groups even when segment-level bounds overlap. | +| Single-column PK probe | Exact equality on a single supported PK | `WHERE id = 42` | Narrows the cold point read with PK segment-index access and Parquet Bloom/min-max metadata. Composite PKs remain conservative. | +| Ordered cold frontier | Supported `ORDER BY` | `ORDER BY created_at DESC LIMIT 10` | Reads catalog composite bounds before Parquet to determine whether cold can outrank the current hot frontier. | +| User scope bounds | Managed user-scoped relation | `WHERE user_id = 'tenant-a'` | Allows scope-column pruning in addition to PostgreSQL RLS. | + +`EXPLAIN ANALYZE` exposes this work through `Cold Segments Query`, `Segment +Index Query`, candidate/open counts, and, for ordered reads, compete/body open +counters. Plain `EXPLAIN` shows planned rather than accumulated runtime values. + ## Executor fast paths BeginCustomScan keeps two common paths out of the expensive merge setup: @@ -166,6 +261,27 @@ EXPLAIN ANALYZE uses the same semantics but initializes profiling state so the reported counters remain meaningful. A hot miss or any uncertain cold state falls through to the merge pipeline. +### Runtime emit-path matrix + +The plan's `Strategy` and the executor's `Actual Access` answer different +questions. An `Exact Primary Key` plan can execute entirely from a hot point +hit, entirely from cold after a hot miss, or delegate a prepared range query to +its native child after parameter binding proves cold empty. + +| Emit path / `Actual Access` | When it runs | Typical query | Work avoided or performed | +| --- | --- | --- | --- | +| `hot_child` / Native PostgreSQL Child | Runtime bounds prove cold empty and a native child exists | `EXECUTE q(1000000)` for a prepared range above all cold bounds | Delegates tuples to the original hot child; no mirror or Parquet setup. | +| `hot_native` / SPI Native Tuple Scan | A full-PK hot probe finds the row, or cold setup yields no source without a native child | `WHERE id = 42` when the current hot heap contains `42` | No Parquet open; the point result is materialized directly. | +| `cold_native` / SPI Native Point Probe | A full-PK hot probe misses but cold may contain the key | `WHERE id = 42` after that row was flushed and pruned hot | Loads the immediate mirror overlay and only cold candidates for the point lookup. | +| `unordered_hot_first` / Native PostgreSQL Child (or SPI JSON fallback) | Unordered logical scan, commonly with `LIMIT` | `SELECT body FROM public.messages LIMIT 20` | Native hot rows flow first. Mirror and Parquet stay deferred if the parent stops before hot exhausts. | +| `ordered_merge_native` / Native PostgreSQL Child (or SPI JSON fallback) | `OrderedProgressive` needs a supported ordered path | `ORDER BY id DESC LIMIT 20` | Uses a hot cursor and cold frontier/row-group competition to stop as soon as the parent limit is satisfied correctly. | +| `merge_stream` / SPI JSON Keyset Scan | Conservative/general merge path | A plan carrying `GeneralMerge` | Reads hot rows in bounded primary-key keyset pages and resolves them against cold and mirror rows. | + +The native-child rows still pass through PostgreSQL's projection, quals, +permissions, and RLS handling. KoldStore may read enough columns to resolve +winners before PostgreSQL evaluates the final user-facing projection and +residual conditions. + ## Merge pipeline For a general cold-capable query, KoldStore loads the active schema/catalog, @@ -202,6 +318,69 @@ resolution. Catalog and mirror reads run under the extension owner; hot source pages use the relation-owner context so RLS cannot hide a newer winner before the invoking role's quals are applied. +### Ordered-progressive cases + +`OrderedProgressive` has several observable subcases. All preserve the SQL +ordering contract; the difference is how much hot/cold data must be read before +the next result is known. + +| Situation | Example | What happens | +| --- | --- | --- | +| Hot frontier strictly dominates cold | Recent IDs remain hot: `ORDER BY id DESC LIMIT 5` | The native ordered hot child supplies the top five. Cold and the deferred mirror can remain unopened. | +| Cold can win | Old IDs or timestamps outrank hot: `ORDER BY id ASC LIMIT 5` after old rows were flushed | KoldStore opens only competitive cold row groups, resolves winners, and returns cold rows before lower-ranked hot rows. | +| Hot/cold overlap | A hot update shares a PK with a cold row | The mirror/hot winner masks the older cold version before ordering and limit are applied. | +| Wide projection with a competitive cold candidate | `SELECT id, body FROM ... ORDER BY id LIMIT 5` | It may open a narrow compete projection (order key + PK) first, then hydrate non-key body columns only for cold winners. | +| Parent limit satisfied after competition by hot winners | `SELECT id, body FROM ... ORDER BY id LIMIT 3` | `Cold Body Opens` stays zero: cold body columns are not read merely to prove they would lose. | +| Narrow projection | `SELECT id FROM ... ORDER BY id LIMIT 5` | Fails open to one full cold projection rather than double-reading compete and body columns. | + +Both ascending and descending supported orderings use the same frontier rule. +An `ORDER BY` on a different expression or mutable column remains correct, but +does not receive this no-external-sort / early-stop promise. + +### Query constructs covered by the merge path + +KoldMergeScan is attached per managed base relation, not per top-level SQL +shape. PostgreSQL can therefore compose it with normal relational operators. +The following examples require the merge path whenever their managed input can +match cold storage: + +```sql +-- Equality, ranges, IN lists, and prepared parameters. +SELECT * FROM public.messages WHERE id IN (1, 2, 3); +SELECT * FROM public.messages WHERE created_at >= now() - interval '7 days'; +PREPARE message_by_id(bigint) AS + SELECT id, body FROM public.messages WHERE id = $1; + +-- Projection and residual expressions are evaluated after winner resolution. +SELECT id, payload->>'kind' +FROM public.messages +WHERE COALESCE(payload->>'kind', '') <> 'internal'; + +-- Aggregates, DISTINCT, joins, semi-joins, and set operations remain PostgreSQL nodes. +SELECT category, count(*) FROM public.messages GROUP BY category; +SELECT DISTINCT category FROM public.messages; +SELECT m.id, a.name +FROM public.messages AS m JOIN public.accounts AS a ON a.id = m.account_id; +SELECT m.id FROM public.messages AS m +WHERE EXISTS (SELECT 1 FROM public.accounts AS a WHERE a.id = m.account_id); +SELECT id FROM public.messages +UNION SELECT archived_id FROM public.archived_message_ids; + +-- Outer and cross joins retain normal PostgreSQL semantics as well. +SELECT m.id, a.name +FROM public.messages AS m LEFT JOIN public.accounts AS a ON a.id = m.account_id; +SELECT m.id, a.name +FROM public.messages AS m RIGHT JOIN public.accounts AS a ON a.id = m.account_id; +SELECT m.id, a.name +FROM public.messages AS m FULL JOIN public.accounts AS a ON a.id = m.account_id; +SELECT m.id, t.label FROM public.messages AS m CROSS JOIN public.tags AS t; +``` + +For each managed base scan, winner resolution happens before PostgreSQL applies +the query's residual predicate, aggregate, join, sort, or limit. This prevents +an older cold version from satisfying a filter after a newer hot update or +tombstone should have hidden it. + ## Query behavior developers rely on | Query state | Expected plan/result behavior | @@ -214,9 +393,10 @@ the invoking role's quals are applied. | ORDER BY, LIMIT, parameters, joins | Cannot bypass the merge path when cold can contribute. | seq identifies a row effect for mirror ordering and flush cutoffs. It is not a -commit-order cursor; durable replay uses WAL LSN. For an unflushed mutation that -must mask old cold data, wait for mirror capture with -koldstore.wait_for_async_mirror() before the read. +commit-order cursor; durable replay uses WAL LSN. For a committed, unflushed +mutation that must mask old cold data, wait for mirror capture with +`koldstore.wait_for_async_mirror()` before acquiring the snapshot used by the +read. The fence cannot expose the current transaction's uncommitted changes. ## Cold-read controls and diagnostics @@ -242,6 +422,12 @@ instrumented execution. | Preload absent when required | Install/manage fails closed; do not rely on a hot-only fallback. | | Cold metadata unavailable or uncertain | Use merge path and surface the error if it cannot execute. | | Merge scan disabled | Error rather than incomplete heap-only result. | +| `SELECT ctid`, `xmin`, or another PostgreSQL system column from a cold-capable managed relation | Error: KoldMergeScan cannot materialize heap system attributes from Parquet. | +| `SELECT ... FOR UPDATE/SHARE` that can reach cold rows | Unsupported: cold rows have no heap TID to lock. | +| `SERIALIZABLE` transaction that can read cold rows | Unsupported as a PostgreSQL-equivalent guarantee: cold reads do not participate in heap predicate locking. | +| `TABLESAMPLE`, inheritance/partition routing, or `TRUNCATE ... CASCADE` involving managed cold data | Not part of the supported preview contract; these shapes must not be assumed hot+cold complete. | +| `koldstore.max_merge_seen_keys` exceeded | Error rather than dropping older keys from winner resolution. | +| `koldstore.cold_reads = off` while cold is required | Error rather than an incomplete hot-only answer. | See [DML](dml-table.md) for overlay production and [mirror capture](mirror-capture.md) for the consistency fence. diff --git a/docs/backup-and-operations.md b/docs/backup-and-operations.md index 2059aec4..3ea00050 100644 --- a/docs/backup-and-operations.md +++ b/docs/backup-and-operations.md @@ -1,49 +1,73 @@ # Backup and Operations -pg-koldstore stores authoritative hot rows in PostgreSQL and cold segment -artifacts in object storage. Because those tiers have separate durability -domains, a recoverable backup must include both tiers and preserve a consistent -point between them. +KoldStore has two durability domains: PostgreSQL owns the hot heap and local +catalog state, while cold row images live in filesystem or object-store +artifacts. The developer preview does not yet ship a coordinated backup, +restore, or point-in-time-recovery protocol across those domains. -## Base Backup and PITR +## Current recovery boundary -Use normal PostgreSQL base backups and WAL archiving for catalog state, heap -rows, row events, jobs, and local manifest cache tables. Point-in-time recovery -must restore PostgreSQL to a time that is consistent with the object-store -backup generation selected for cold artifacts. +A PostgreSQL base backup, WAL archive, or logical dump is not sufficient to +recover a managed table after rows have been pruned from the heap. Copying an +object prefix beside a PostgreSQL backup is also not, by itself, a consistent +snapshot: catalog publication and object writes have to agree on the same +manifest generation. -## Object Backup +Until a generation-pinning protocol is implemented, treat managed data as +non-production data unless the application has an independent authoritative +copy. Operators experimenting with manual snapshots must capture PostgreSQL +and immutable cold prefixes, record the active manifest/catalog identities, +retain every referenced object, and validate the pairing before cutover. That +is an operator procedure, not a recovery guarantee provided by KoldStore. -Back up the configured object-store prefixes for every registered -`koldstore.storage` row. Cold artifacts are retained by default during -demigration and DROP TABLE cleanup unless an operator explicitly enables -deletion. +## `pg_dump` and `COPY` -## Validation and Recovery +These commands do not all enter the planner in the same way: -`koldstore.table_status` summarizes hot rows, cold segment counts, manifest -state, pending jobs, storage binding, and the last recorded error. -`koldstore.backup_manifest` exports the local manifest identity required to -match a PostgreSQL backup to cold files. `koldstore.validate_cold_storage` -checks manifest, Parquet, stats, PK hint, and catalog consistency surfaces. -`koldstore.recover_segments` discovers orphan cold objects under the table -prefix (and expires stale `pending` catalog rows), then quarantines or deletes -them inline. It does **not** enqueue recovery jobs. +- `pg_dump --data-only -t managed_table` reads the physical heap and therefore + omits rows that exist only in cold storage. +- `COPY managed_table TO ...` likewise exports the heap relation and can omit + cold-only rows. +- `COPY (SELECT ... FROM managed_table) TO ...` plans the query and can use + `KoldMergeScan`, so it includes cold rows for query shapes within the + supported scan contract. +- `COPY FROM` writes the heap. It does not provide global hot+cold uniqueness or + conflict checking. -## pg_dump, COPY, and Logical Replication +Do not present a plain dump or direct table `COPY` as a logical backup of a +managed relation. A KoldStore-aware backup/export workflow and explicit +failure/diagnostics for unsafe dump paths are tracked separately from the +planned operator APIs in +[#103](https://github.com/kalamdb/koldstore/issues/103). +The end-to-end backup, restore, PITR, and unsafe-dump contract is tracked in +[#126](https://github.com/kalamdb/koldstore/issues/126). -`pg_dump` and `COPY (SELECT ...) TO` export the logical merged view. `COPY FROM` -is supported only for managed shared tables unless user-scope enforcement has -an active `koldstore.user_id`. Physical cold artifacts are not represented in -plain SQL dumps. +## Object lifecycle -Logical replication sees hot heap changes and SQL API effects. Consumers that -need cold-only updates should read `koldstore.changes_since` using commit-order -cursors and retention-gap handling. +Current `DROP TABLE` cleanup deletes cold objects inline before the PostgreSQL +DDL transaction commits. Because object storage does not roll back with +PostgreSQL, aborting the transaction can leave restored catalog state pointing +at missing objects. Durable, asynchronous post-commit garbage collection is +tracked in [#100](https://github.com/kalamdb/koldstore/issues/100). -## Export and Import +The `drop_cold` argument to `unmanage_table` is currently planned by the SQL +surface but is not executed. Do not use it as a retention guarantee. -`koldstore_exec('EXPORT TABLE ...')` is the archive boundary for writing a -kalamdb-compatible manifest and Parquet archive. `IMPORT TABLE` is intentionally -rejected until ownership, conflict handling, and schema compatibility rules are -implemented end to end. +## Available diagnostics and planned APIs + +`koldstore.table_status` reports current table, manifest, segment, job, and +async-mirror information. It is operational telemetry, not a backup manifest. + +The following interfaces are planned but are not shipped SQL functions: + +- `koldstore.backup_manifest` +- `koldstore.validate_cold_storage` +- packaged export/import + +`koldstore.recover_segments` is a maintenance surface for orphan/pending +objects; it does not create a coordinated backup or reconstruct arbitrary +missing cold data. + +Logical replication captures source-heap changes, not a portable snapshot of +the cold object set. Downstream consumers must not infer that subscribing to +the source publication reproduces a managed table's existing cold history. diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index d78014bd..ba72a0ef 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -39,7 +39,7 @@ for your hardware. See [Mirror capture](../architecture/mirror-capture.md). **Managed PostgreSQL sizes always include** the hot user heap **plus** -`koldstore.
__cl` (latest-state change-log mirror) **and** that mirror’s +`koldstore._
__cl` (latest-state change-log mirror) **and** that mirror’s indexes (PK + `seq` + partial tombstone). Cold Parquet is listed separately and is outside the PostgreSQL data directory. Report **local PostgreSQL** and **total hot+cold** as separate rows — combining them into one “99% smaller” @@ -53,10 +53,14 @@ by the harness (cluster RSS polled every 50ms during `flush_table`). - **Tradeoff** is relative to plain PostgreSQL on the same machine/run (slower / faster / smaller). -- **Hot-only queries** are timed **before flush**, so both heaps still hold all - 10M rows — that isolates `KoldMergeScan` overhead vs a plain index lookup, - not “smaller heap wins.” The timed SQL is a repeated point lookup of the - **newest** PK (`WHERE id = `), not a scan of the whole table. +- **Hot-only queries (before flush)** are timed **before flush**, so both heaps + still hold all rows — that isolates planner/hook overhead vs a plain index + lookup, not “smaller heap wins.” The timed SQL is a repeated point lookup of + the **newest** PK (`WHERE id = `), not a scan of the whole table. +- **Hot-only queries (after flush)** repeat that same newest PK **after** policy + flush on managed (row still hot). The plan must stay native Index Scan or + `KoldMergeScan` with **0 Parquet segments**. PostgreSQL-only times the same + SQL on the full pre-`VACUUM FULL` heap as the baseline. - **PostgreSQL-only cold-id / hot+cold** also run **before** `VACUUM FULL` on the full heap (same post-DML state as hot-only). Measuring them after a whole-table rewrite would compare a freshly compacted 10M heap to managed diff --git a/docs/benchmarks/RESULTS.md b/docs/benchmarks/RESULTS.md index 26de7b4b..9da376eb 100644 --- a/docs/benchmarks/RESULTS.md +++ b/docs/benchmarks/RESULTS.md @@ -16,7 +16,7 @@ this file. Each column is measured alone on a wiped + re-initdb pgrx PostgreSQL **Git:** `b220d79339ac` (`b220d79339ac08a20e3921125be2a7df8f7005a9`) — draft stamp (`KOLDSTORE_STORAGE_DRAFT_RESULTS=1`) **Run:** 10000000 rows · `hot_row_limit = 100000` · `max_rows_per_file = 1000000` · `--dml-sample 50000` · `insert_batch_rows = 100000` · `warmup_rows = 1000000` · zstd Parquet · **counterbalanced sequential** isolated wiped server per sample (not parallel) · sides measured: **pg + async** · **single sample per side** · `changes_since` drain skipped -Managed PostgreSQL sizes include hot heap + `koldstore.
__cl` + mirror +Managed PostgreSQL sizes include hot heap + `koldstore._
__cl` + mirror indexes. Cold Parquet is outside the PostgreSQL data directory. Columns are **PostgreSQL only** and **PG + KoldStore** (WAL-only capture). diff --git a/docs/decisions/003-optional-async-mirror-capture.md b/docs/decisions/003-optional-async-mirror-capture.md index f4af4da4..967d468e 100644 --- a/docs/decisions/003-optional-async-mirror-capture.md +++ b/docs/decisions/003-optional-async-mirror-capture.md @@ -58,7 +58,7 @@ PostgreSQL forbids creating a logical slot in a transaction that has performed writes. `wal_level=logical` and its server restart remain the only manual administrator prerequisite. -Expose `koldstore.wait_for_async_mirror()` as an explicit strong-consistency +Expose `koldstore.wait_for_async_mirror()` as an explicit committed-change fence. It peeks committed pgoutput v1 changes, parses them in Rust, and applies 8,192-row set-based mirror batches. Mirror writes and `koldstore.async_mirror_state.applied_lsn` commit together. The next fence @@ -70,8 +70,10 @@ One dynamic worker per database polls every 100 ms, avoids reopening logical decoding at an unchanged WAL position, and applies committed WAL when it moves. An async-only statement trigger ensures the worker exists without performing mirror work in the source transaction, including after PostgreSQL restart. -Strong reads retain the explicit fence. Logical apply and manual fences share a -database advisory lock so only one consumer touches the slot at a time. +Reads that require a committed mirror boundary retain the explicit fence. It +does not provide read-your-own-uncommitted-writes or advance an existing +snapshot. Logical apply and manual fences share a database advisory lock so +only one consumer touches the slot at a time. ## Alternatives Considered diff --git a/docs/development.md b/docs/development.md index a7e27883..737da203 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,6 +14,21 @@ defaults match CI (`2000` / `10000` rows). ## Local Build +Debug builds use `debug = "line-tables-only"` (file/line backtraces, no full +DWARF). The repo `rust-toolchain.toml` is nightly so `.cargo/config.toml` can +use `-Z threads=8` for the parallel frontend. Optional Cranelift for local +`profile.dev` (nightly only; not committed, because Cargo 1.96 rejects that +unstable profile key): + +```bash +export CARGO_PROFILE_DEV_CODEGEN_BACKEND=cranelift +``` + +Release / `release-pg` profiles stay on LLVM. CI and the `rust:1.96` Docker +image set `RUSTUP_TOOLCHAIN=1.96.0` (so they ignore the nightly +`rust-toolchain.toml`) and clear `CARGO_ENCODED_RUSTFLAGS` so those jobs never +pass `-Z` to stable rustc. + ```bash cargo fmt --all cargo check --workspace --all-targets --no-default-features @@ -180,17 +195,20 @@ the Release workflow after setting `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` `docker/image-description.txt` after a successful push. ```bash -docker pull ghcr.io/kalamdb/pg-koldstore:latest +docker pull ghcr.io/kalamdb/pg-koldstore:latest # PostgreSQL 18 docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 ghcr.io/kalamdb/pg-koldstore:latest # or: docker pull jamals86/pg-koldstore:latest -# psql postgres://postgres:postgres@127.0.0.1:5432/koldstore +# PostgreSQL 16: jamals86/pg-koldstore:pg16 / :-pg16 +# psql postgres://postgres:postgres@127.0.0.1:5432/koldstoredb # koldstore is created on first boot; built-in auto-flush handles hot_row_limit # pg_cron is optional if you want to schedule manual flush_table yourself ``` Local source builds still use `docker/run.sh` / `docker/Dockerfile` (compiles the -extension). The release image uses `docker/Dockerfile.release` and -`docker/test-release-image.sh`. Docker Hub overview content lives in +extension). The release image uses `docker/Dockerfile.release` (default +`PG_MAJOR=18`) and `docker/test-release-image.sh`. Release CI publishes PG18 +(amd64+arm64, `:latest`) and PG16 (amd64); PG17 (amd64) is opt-in via +`docker_push_pg17`. Docker Hub overview content lives in `docker/image-description.txt` and is synced by the Release workflow — not from the project `README.md`. @@ -228,7 +246,8 @@ tests/memory/run_memory_checks.sh Runs probe unit tests, then the deep E2E leak gates in `tests/e2e/suite/memory_leak.rs` (flush + hot DML + merge-scan SELECT loops; -MinIO parquet reads when `KOLDSTORE_MINIO=1`). Also prints a plain-Postgres vs +MinIO parquet reads when `KOLDSTORE_MINIO=1`), peak-spike gates, and +per-process WAL/flush footprint gates. Also prints a plain-Postgres vs koldstore comparison table (idle / DML / hot-only / flush / hot+cold) with context+RSS before/after/Δ/spike columns. Snapshots use `pg_backend_memory_contexts` plus process RSS. Set diff --git a/docs/limitations.md b/docs/limitations.md index 955d3326..79a2ce44 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -19,6 +19,52 @@ managed tables and return **hot-only** rows after flush. Check with `SELECT koldstore.preload_status();`. +## PostgreSQL semantic compatibility + +The original relation remains a PostgreSQL heap, but a cold row returned from +Parquet is not a heap tuple. The preview therefore does not claim that every +PostgreSQL operation keeps its normal semantics across both tiers. + +- Mirror capture is asynchronous and sees committed WAL only. It does not + provide read-your-own-uncommitted-writes for a key with an older cold version + ([#121](https://github.com/kalamdb/koldstore/issues/121)). +- `wait_for_async_mirror()` fences commits up to a captured WAL boundary. Call + it before acquiring a fixed `REPEATABLE READ` or `SERIALIZABLE` snapshot; it + cannot advance an existing snapshot or decode the caller's uncommitted work. +- Normal `UPDATE` and `DELETE` cannot target a row that exists only in cold + storage. Native `INSERT ... ON CONFLICT` and primary-key checks inspect the + hot index, not a global hot+cold constraint index + ([#122](https://github.com/kalamdb/koldstore/issues/122)). +- Cold rows have no heap `ctid`, `xmin`, tuple lock, or SSI predicate lock. + System-column projections and `SELECT ... FOR UPDATE/SHARE` are unsupported; + `SERIALIZABLE` is not a PostgreSQL-equivalent guarantee for cold reads. +- Partitioned/inherited/foreign/temporary/unlogged relations, `TABLESAMPLE`, + and `TRUNCATE ... CASCADE` are outside the supported preview contract unless + a specific test documents otherwise + ([#125](https://github.com/kalamdb/koldstore/issues/125)). +- Table/schema renames after cold publication are unsafe because object paths + still depend on mutable names. Other schema evolution can apply in PostgreSQL + before KoldStore discovers it is unsupported; defaults and constraints are + not retroactively enforced on older Parquet rows + ([#123](https://github.com/kalamdb/koldstore/issues/123)). +- `pg_dump --data-only -t table` and `COPY table TO` export the heap and can omit + cold-only rows. Only a planned query such as `COPY (SELECT ...) TO` can enter + `KoldMergeScan`, and coordinated backup/PITR is not shipped + ([#126](https://github.com/kalamdb/koldstore/issues/126)). + +The generated user-scope policy is application-context filtering, not an +authentication boundary. `koldstore.user_id` is a user-settable GUC, and the +generated policy is permissive, so another permissive policy can broaden the +combined RLS expression. Use a trusted connection layer and dedicated roles; +do not advertise this surface as database-enforced tenant isolation. Management +API privilege hardening is tracked in +[#120](https://github.com/kalamdb/koldstore/issues/120). + +Planner cardinality and cost estimates are also a preview limitation. Current +estimates can reflect the hot child rather than the logical hot+cold row set; +cold-aware statistics work is tracked in +[#124](https://github.com/kalamdb/koldstore/issues/124). + ## Unique and Foreign Key Constraints PostgreSQL `UNIQUE` and foreign-key constraints on managed tables are enforced on @@ -35,7 +81,7 @@ accounting, not for proving that a unique value is absent from cold storage on | Constraint | Hot rows | Cold rows | Normal DML checks cold? | |------------|----------|-----------|-------------------------| -| Primary key | Yes | Logical winner via merge | Cold presence via `cold_segment_index` + Parquet; not a full UNIQUE layer | +| Primary key | Yes | Logical winner via merge | No; the native check is hot-only | | `UNIQUE` (non-PK) | Yes | No | No | | Foreign keys | Yes | No | No | @@ -210,8 +256,8 @@ and a small-host checklist. | Feature | Hot rows | Cold rows | |---------|----------|-----------| -| Normal SQL select | Yes | Yes, through Kalam cold reader | -| Primary key enforcement | Yes | Winner resolved by merge | +| Supported `SELECT` shapes | Yes | Yes, through `KoldMergeScan` | +| Primary key enforcement | Yes | No global enforcement; winner resolution only | | `UNIQUE` (non-PK) | Yes | No | | Foreign keys | Yes | No | | PostgreSQL custom indexes | Yes | No | diff --git a/docs/operations/scheduling.md b/docs/operations/scheduling.md index fc505b03..029eec20 100644 --- a/docs/operations/scheduling.md +++ b/docs/operations/scheduling.md @@ -34,7 +34,9 @@ On each `flush_check_interval_seconds` tick ephemeral maintenance: excess with `max_rows_per_file = 1000`) are skipped — no job row is created. WAL application is not part of this tick: the persistent WAL applier runs as a -separate latch-driven service. See [mirror-capture.md](../architecture/mirror-capture.md). +separate latch-driven service. Process fork vs interval semantics are in +[jobs-and-scheduler — Process lifecycle](../architecture/jobs-and-scheduler.md#process-lifecycle). +See also [mirror-capture.md](../architecture/mirror-capture.md). ## Built-in scheduler @@ -66,25 +68,18 @@ generation instead of queueing one job per transaction. Each apply tick runs in `async_mirror_state.applied_lsn` commit together (or roll back together on ERROR). While idle, the applier holds no open transaction. -The applier does not periodically decode on a short poll interval. A safety -watchdog controlled by `koldstore.async_apply_watchdog_interval_ms` (default -`30000`, clamped to `1000..=300000`) catches a lost notification or a two-phase -commit that cannot carry the originating backend's in-memory hint. +The applier does not periodically decode on a short poll interval. Idle wait is +`WaitLatch` with a 30-second timeout (`WAL_APPLIER_WATCHDOG`) so a missed +notification or two-phase commit still recovers. Managed commits `SetLatch` +immediately; that timeout is not the normal apply cadence. -```sql --- Per-database (preferred for the bgworker): -ALTER DATABASE mydb SET koldstore.async_apply_watchdog_interval_ms = 30000; --- Restart the WAL applier (or terminate + ensure) so it reconnects with --- the new database default. SIGHUP also reloads ALTER SYSTEM values. - --- Or persist cluster-wide: -ALTER SYSTEM SET koldstore.async_apply_watchdog_interval_ms = 30000; -SELECT pg_reload_conf(); -``` +`koldstore.async_apply_watchdog_interval_ms` is registered (default `30000`, +clamped to `1000..=300000`) but the applier loop does not read it today. Changing +it does not change the idle wait until that wiring exists. Session `SET` does not affect background workers. Prefer `ALTER DATABASE` -or `ALTER SYSTEM` + reload / worker restart, matching -`flush_check_interval_seconds`. +or `ALTER SYSTEM` + reload / worker restart for GUCs the workers do read, +matching `flush_check_interval_seconds`. ### Async retained-WAL health threshold diff --git a/docs/operations/upgrade.md b/docs/operations/upgrade.md index 5862e72c..a3f5a373 100644 --- a/docs/operations/upgrade.md +++ b/docs/operations/upgrade.md @@ -65,11 +65,13 @@ Prefer `ALTER DATABASE` / `ALTER SYSTEM` for background-worker GUCs (session | `koldstore.flush_check_interval_seconds` | `30` (default) or tuned | Built-in auto-flush enqueue cadence | | `koldstore.flush_execution` | `queue` (default) | Production enqueue-and-return; `inline` is SPI tests only | | `koldstore.max_parallel_flush_jobs` | `2` (default) or tuned | Concurrent one-shot flush executors per database | -| `koldstore.async_apply_watchdog_interval_ms` | `30000` (default) | Safety recovery for missed commit wakeups | +| `koldstore.async_apply_watchdog_interval_ms` | `30000` (default) | Registered; the applier's idle `WaitLatch` timeout is currently hardcoded 30 s, not this GUC | `koldstore.async_apply_poll_interval_ms` was removed. Managed commits wake the -worker directly; keep only the watchdog GUC above and drop any leftover -`async_apply_poll_interval_ms` lines from `postgresql.conf` / `ALTER DATABASE`. +worker directly. Drop any leftover `async_apply_poll_interval_ms` lines from +`postgresql.conf` / `ALTER DATABASE`. The idle `WaitLatch` timeout is 30 s in +the applier; `koldstore.async_apply_watchdog_interval_ms` is registered at that +default but is not read by the loop. Also alert on `koldstore.async_mirror_status()` (`healthy`, retained bytes, `updated_at` age). See [scheduling.md](scheduling.md) and diff --git a/docs/plans/2026-08-09-compact-cold-scan-explain.md b/docs/plans/2026-08-09-compact-cold-scan-explain.md new file mode 100644 index 00000000..5b21ca5b --- /dev/null +++ b/docs/plans/2026-08-09-compact-cold-scan-explain.md @@ -0,0 +1,116 @@ +# Compact Cold-Scan EXPLAIN Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make KoldMergeScan EXPLAIN output compact by default while exposing enough phase timing to attribute catalog lookup, Parquet open/footer work, object-store I/O, and scan/decode time. + +**Architecture:** Keep the existing hot/cold execution paths unchanged. Extend the Parquet read profile with measured open, object-store I/O, and scan/decode durations; aggregate those measurements in the PostgreSQL-facing cold profile. Normal EXPLAIN shows aggregate counters and timings, while EXPLAIN VERBOSE owns raw SQL and per-segment diagnostics. + +**Tech Stack:** Rust, pgrx/PostgreSQL EXPLAIN APIs, Apache Parquet async reader, object_store, cargo-pgrx tests. + +--- + +### Task 1: Lock the compact EXPLAIN contract + +**Files:** +- Modify: `crates/pg_koldstore/src/pg_tests/scan.inc.rs` + +**Step 1: Write the failing test** + +Extend the cold EXPLAIN regression so ordinary timed `EXPLAIN ANALYZE` requires aggregate `Parquet Open Time`, `Object Store Read Time`, and `Parquet Scan Time`, and rejects `Cold Segments Query`, `Segment Index Query`, and the `Parquet Segments` detail group. Add a VERBOSE assertion that the raw catalog SQL and per-segment object detail remain available. + +**Step 2: Run the test to verify it fails** + +Run: `cargo pgrx test pg18 explain_analyze_shows_scan_merge_flow_and_phase_timing --package pg_koldstore` + +Expected: FAIL because the aggregate phase fields do not exist and normal output still contains SQL/per-segment detail. + +### Task 2: Measure Parquet open and object-store I/O + +**Files:** +- Modify: `crates/koldstore-parquet/src/object_reader.rs` +- Modify: `crates/koldstore-parquet/src/reader/options.rs` +- Modify: `crates/koldstore-parquet/src/reader/object_store.rs` + +**Step 1: Add a failing pure-Rust profile test** + +Add assertions around a typed I/O snapshot and `ParquetReadProfile` timing summary so call count, byte count, and accumulated object-store wait are reported independently. + +**Step 2: Run the focused test to verify it fails** + +Run: `cargo test --package koldstore-parquet object_store_read_stats` + +Expected: FAIL because the timing snapshot fields do not exist. + +**Step 3: Implement the minimal timing collection** + +Time each `get_range`, `get_ranges`, and suffix `get_opts` operation and accumulate nanoseconds atomically. Measure `ParquetRecordBatchStreamBuilder::new_with_options` as open/footer time and the remaining row-group selection/build/iteration work as scan time. Store durations in `ParquetReadProfile` without changing read semantics. + +**Step 4: Run the focused test** + +Run: `cargo test --package koldstore-parquet object_store_read_stats` + +Expected: PASS. + +### Task 3: Render compact aggregate timing and VERBOSE details + +**Files:** +- Modify: `crates/pg_koldstore/src/merge_scan/pg/profile.rs` + +**Step 1: Aggregate the new profile durations** + +Add helpers that sum per-segment open, object-store I/O, and scan durations. Keep `Cold Read Time` as end-to-end Parquet wall time so existing tooling remains compatible. + +**Step 2: Change the default/VERBOSE split** + +In ordinary EXPLAIN, emit aggregate cold counters and the new timing fields only. Emit raw `Cold Segments Query`, `Segment Index Query`, `Hot SPI Query`, and the `Parquet Segments` group only when `ExplainState.verbose` is true. Preserve structured pipeline nodes, but keep raw SQL conditional on VERBOSE. + +**Step 3: Run the pgrx regression** + +Run: `cargo pgrx test pg18 explain_analyze_shows_scan_merge_flow_and_phase_timing --package pg_koldstore` + +Expected: PASS. + +### Task 4: Validate the catalog-query concern + +**Files:** +- Inspect: `crates/koldstore-catalog/src/queries.rs` +- Inspect: `crates/pg_koldstore/sql/koldstore--0.1.0.sql` +- Inspect: the six `/tmp/koldstore-mem/prod-probe-60047/explain_*.txt` captures + +**Step 1: Compare measured catalog time with total execution time** + +Use `Segment Catalog Time` and `Segment Index Lookup Time` from the captures to determine whether SQL complexity is an observed latency source. + +**Step 2: Check access-shape intent** + +Confirm the segment-index `UNION ALL` is deliberate so min/max and unknown-bound arms remain indexable, and confirm supporting partial/B-tree indexes exist. + +**Step 3: Avoid an unsupported query rewrite** + +If catalog work remains sub-millisecond to roughly one millisecond and the access shape is indexed, retain the SQL and remove it from default EXPLAIN noise. Only create a separate query-architecture change if an actual catalog plan demonstrates a material bottleneck. + +### Task 5: Verify the complete change + +**Files:** +- Verify all touched Rust files + +**Step 1: Format** + +Run: `cargo fmt --all` + +**Step 2: Run focused tests** + +Run: `cargo test --package koldstore-parquet object_store_read_stats` + +Run: `cargo pgrx test pg18 explain_analyze_shows_scan_merge_flow_and_phase_timing --package pg_koldstore` + +**Step 3: Compile-check affected crates** + +Run: `cargo check --package koldstore-parquet --package pg_koldstore` + +**Step 4: Check formatting and review scope** + +Run: `cargo fmt --all -- --check` + +Review `git diff` and `git status --short` to confirm the existing `docker/docker-compose.release.yml` edit is untouched. diff --git a/docs/plans/2026-08-10-merge-scan-core-ownership.md b/docs/plans/2026-08-10-merge-scan-core-ownership.md new file mode 100644 index 00000000..cd03c92a --- /dev/null +++ b/docs/plans/2026-08-10-merge-scan-core-ownership.md @@ -0,0 +1,215 @@ +# Merge-Scan Core Ownership and Performance Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILLS: use `test-driven-development`, +> `rust-skills`, `cargo-pgrx`, `performance-optimization`, and +> `verification-before-completion` while executing this plan task-by-task. + +**Goal:** Move PostgreSQL-free merge decisions out of `pg_koldstore`, remove +duplicated/legacy adapter logic, and reduce merge-path allocations without +regressing native hot-only reads or hot+cold correctness. + +**Architecture:** `koldstore-merge` owns winner masking, ordered-frontier +selection, row-group set operations, and cold projection planning. +`pg_koldstore::merge_scan::pg` remains a thin owner of PostgreSQL hooks, SPI, +catalog access, Parquet opening, tuple conversion, and EXPLAIN. Changes land in +small test-gated slices and retain the existing progressive execution model. + +**Tech Stack:** Rust, pgrx/PostgreSQL CustomScan, Arrow/Parquet, Criterion, +local pgrx PostgreSQL. + +**Existing WIP:** Continue on `codex/stabilize-wal-flush-workers`; preserve the +pre-existing edits in `cold.rs`, `execute.rs`, and `profile.rs`. Do not switch +branches or strand those changes. + +**Locked paths:** Do not change the empty-manifest planner return, +`cold_side_proven_empty`, `EmitPath::HotChild`, the exact-PK hot probe, or the +rule that every potentially satisfiable cold query uses KoldMergeScan. + +--- + +### Task 1: Make ordered row-group selection conservative and reusable + +**Files:** + +- Modify: `crates/koldstore-merge/src/scan/ordered_merge.rs` +- Modify: `crates/koldstore-merge/src/scan/mod.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/cold.rs` + +**Step 1: Write failing pure-Rust tests** + +Add tests proving that mismatched min/max catalog arrays retain unmatched row +groups as unknown, and that intersecting planned and competitive row groups +preserves planned order without repeated linear `contains` scans. + +**Step 2: Verify RED** + +Run: + +```bash +cargo test -p koldstore-merge scan::ordered_merge +``` + +Expected: the mismatched-array case drops an unknown row group and the new +intersection API is not implemented. + +**Step 3: Implement the minimal core helpers** + +Iterate to the maximum bound-array length and treat a missing directional bound +as unknown/competitive. Add an allocation-conscious intersection helper using +sorted membership checks, then replace the adapter's quadratic +`selected.contains` loop. + +**Step 4: Verify GREEN** + +Run the focused tests and `cargo test -p koldstore-merge`. + +--- + +### Task 2: Centralize tombstone masking in the merge resolver + +**Files:** + +- Modify: `crates/koldstore-merge/src/core/resolver.rs` +- Modify: `crates/koldstore-merge/tests/resolver.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/execute.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/mirror.rs` + +**Step 1: Extend resolver characterization tests** + +Cover immediate and deferred tombstone masks, duplicate masked keys, live hot +winners followed by masking, and seen-key cap behavior. + +**Step 2: Keep the tests green while refactoring** + +Make `NewestFirstWinnerResolver` the single masking authority. Count overlay +matches without allocating a replacement cold vector, apply deferred masks to +the resolver before resolving the batch, and delete the adapter-owned +`filter_cold_rows_with_overlay` implementation. + +**Step 3: Verify** + +Run resolver tests, the full merge crate tests, and `cargo check -p pg_koldstore`. + +--- + +### Task 3: Reduce winner-resolution cloning and duplicate batch logic + +**Files:** + +- Modify: `crates/koldstore-merge/src/core/resolver.rs` +- Modify: `crates/koldstore-merge/tests/resolver.rs` +- Modify: `benchmarks/benches/extension_serialization.rs` only if a separate + streaming benchmark is needed + +**Step 1: Record the before measurement** + +Use the existing 10k hot + 10k cold Criterion case. Current median baseline: +`4.224 ms` for `deduplicate_hot_and_cold_by_primary_key`. + +**Step 2: Add equivalence coverage** + +Prove borrowed and owned resolution select identical winners, tie-breaking, +tombstones, and canonical PK output. + +**Step 3: Refactor after the characterization gate** + +Resolve borrowed inputs through borrowed candidates so only final winner row +images are cloned. Pre-size winner maps. Consolidate duplicated hot/cold batch +loops through a monomorphized internal helper without dynamic dispatch. + +**Step 4: Measure after** + +Re-run the same Criterion filter and report the confidence interval. Keep the +change only if it is neutral or faster and tests remain green. + +--- + +### Task 4: Move cold compete/body projection planning into `koldstore-merge` + +**Files:** + +- Create: `crates/koldstore-merge/src/scan/projection.rs` +- Modify: `crates/koldstore-merge/src/scan/mod.rs` +- Modify: `crates/koldstore-merge/src/lib.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/cold.rs` + +**Step 1: Write failing projection tests** + +Cover leading-PK deduplication, composite PKs, narrow projections that disable +late materialization, stable body order, and body hydration's `body + PK` +projection. + +**Step 2: Verify RED** + +Run the new module tests; expect the projection planner assertions to fail +against a minimal scaffold. + +**Step 3: Implement and wire the core plan** + +Represent `Full`, `Compete`, and `Body` column ownership with one typed plan. +Keep actual Parquet opens and profiles in `pg_koldstore`, but remove the +duplicated set construction from `ColdRowStream`. + +**Step 4: Verify GREEN** + +Run merge tests and the pg crate check. + +--- + +### Task 5: Thin PostgreSQL adapter duplication and argument plumbing + +**Files:** + +- Modify: `crates/pg_koldstore/src/merge_scan/pg/hot_cursor.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/literals.rs` +- Modify: `crates/pg_koldstore/src/merge_scan/pg/cold.rs` + +**Step 1:** Reuse the single existing relabel-expression helper instead of the +byte-for-byte duplicate in `hot_cursor.rs`. + +**Step 2:** Replace newly added `too_many_arguments` suppressions with focused +request/context structs when doing so shortens call sites and does not add +runtime indirection. + +**Step 3:** Run `cargo check -p pg_koldstore` after each adapter cleanup. + +--- + +### Task 6: PostgreSQL and performance verification + +**Files:** + +- Modify only if behavior changes: `docs/architecture/scanning-table.md` +- Test existing merge coverage under `crates/pg_koldstore/src/pg_tests/` and + `tests/e2e/merge/` + +**Step 1: Static gates** + +```bash +cargo fmt --all +cargo fmt --all -- --check +cargo clippy -p koldstore-merge --all-targets -- -D warnings +cargo check -p pg_koldstore +``` + +**Step 2: Pure and in-server behavior** + +```bash +cargo test -p koldstore-merge +cargo pgrx test -p pg_koldstore pg18 +``` + +Verify hot PK hits, empty/proven-empty cold paths, ordered hot-dominant LIMIT, +cold-winning LIMIT, overlap/tombstone masking, parameters, joins, and rescan. + +**Step 3: Performance gate** + +Re-run the Criterion deduplication benchmark and existing pg benches for native +hot, managed hot, and cold lifecycle where feasible. Do not claim a speedup +without before/after measurements. + +**Step 4: Final review** + +Inspect the diff for unrelated WIP, unsafe-boundary expansion, public API churn, +dead exports, outdated comments, and architecture-contract changes. Update +`scanning-table.md` only if user-visible behavior or invariants changed. diff --git a/docs/plans/2026-08-10-mirror-schema-names.md b/docs/plans/2026-08-10-mirror-schema-names.md new file mode 100644 index 00000000..a79ecc41 --- /dev/null +++ b/docs/plans/2026-08-10-mirror-schema-names.md @@ -0,0 +1,59 @@ +# Schema-Qualified Mirror Names Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Prevent mirror sharing between same-named source tables and preserve that uniqueness across source schema and table renames. + +**Architecture:** Generate mirrors as `koldstore.___cl`, retaining a deterministic hash suffix only when PostgreSQL's identifier limit requires it. Extend the ProcessUtility rename follow-up to rehome every generated mirror artifact, and use PostgreSQL-backed regression tests for collisions and rename reuse. + +**Tech Stack:** Rust, pgrx, PostgreSQL ProcessUtility hook, `cargo pgrx test`. + +--- + +### Task 1: Reproduce schema collision and rename behavior + +**Files:** +- Modify: `crates/pg_koldstore/src/pg_tests/manage.inc.rs` + +**Step 1:** Add `#[pg_test]` coverage that manages `schema_a.messages` and `schema_b.messages`, verifies distinct mirrors, and proves one can be unmanaged without dropping the other. + +**Step 2:** Add a `#[pg_test]` that renames a managed source table and schema, verifies its artifacts adopt the new derived names, and manages a new table at the old name. + +**Step 3:** Run the focused pgrx tests and confirm they fail against the unmodified implementation. + +### Task 2: Generate schema-qualified, bounded mirror identifiers + +**Files:** +- Modify: `crates/koldstore-wal-mirror/src/mirror/shared/relation.rs` +- Test: the crate's existing relation/planning tests + +**Step 1:** Add pure-Rust tests for ordinary schema-qualified output and deterministic bounded output. + +**Step 2:** Implement the naming rule with a stable hash fallback for identifiers over PostgreSQL's 63-byte limit. + +**Step 3:** Run the narrow unit tests. + +### Task 3: Rehome artifacts after source relation renames + +**Files:** +- Modify: `crates/koldstore-wal-mirror/src/mirror/shared/schema.rs` +- Modify: `crates/koldstore-wal-mirror/src/mirror/guard.rs` +- Modify: `crates/pg_koldstore/src/sql/migrate/schema_registry.rs` +- Modify: `crates/pg_koldstore/src/hooks/ddl.rs` + +**Step 1:** Plan the table/index and guard function/trigger rename DDL from the old and new mirror identities. + +**Step 2:** Invoke the plan after table renames and for all managed tables affected by `ALTER SCHEMA ... RENAME TO`. + +**Step 3:** Run the focused regressions and `cargo check`. + +### Task 4: Verify and format + +**Files:** +- Modify: touched Rust and test files only + +**Step 1:** Run `cargo fmt --all`. + +**Step 2:** Run the focused pgrx tests, workspace check, and the appropriate full package tests. + +**Step 3:** Review the diff and verify the active branch has only this fix's changes. diff --git a/docs/plans/2026-08-10-parquet-writer-layout-options.md b/docs/plans/2026-08-10-parquet-writer-layout-options.md new file mode 100644 index 00000000..8935dcb2 --- /dev/null +++ b/docs/plans/2026-08-10-parquet-writer-layout-options.md @@ -0,0 +1,53 @@ +# Parquet Writer Layout Options Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Expose Parquet row-group, data-page, and Bloom false-positive-rate options per managed table and use them for future flushes. + +**Architecture:** Store validated values in `ManageTableOptions`, thread them through `FlushPreparedContext` and `StreamEncodeInput`, and construct `WriterOptions` from that input. `manage_table` and the existing `ALTER TABLE` option hook are the two configuration entrypoints. PK-derived pruning/Bloom defaults remain derived rather than being persisted as overrides. + +**Tech Stack:** Rust, pgrx, PostgreSQL, parquet-rs, Rust E2E tests. + +--- + +### Task 1: Specify and prove the new `manage_table` SQL contract + +**Files:** +- Modify: `tests/e2e/flush/mod.rs` +- Create: `tests/e2e/flush/parquet_layout_options.rs` +- Modify: `crates/pg_koldstore/src/sql/migrate/mod.rs` +- Modify: `crates/pg_koldstore/src/sql/migrate/manage.rs` +- Modify: `crates/koldstore-migrate/src/validation/manage_table.rs` +- Modify: `crates/koldstore-common/src/config/options.rs` + +**Step 1:** Write an E2E that invokes `manage_table` with layout options and fails because the SQL arguments do not exist. + +**Step 2:** Add typed, positive-only persisted options and wire the SQL arguments through validation. + +**Step 3:** Run the focused E2E to prove the SQL API succeeds. + +### Task 2: Apply settings to future Parquet writes + +**Files:** +- Modify: `crates/pg_koldstore/src/sql/flush/execute.rs` +- Modify: `crates/koldstore-flush/src/encode.rs` +- Modify: `tests/e2e/flush/parquet_layout_options.rs` + +**Step 1:** Extend the E2E to inspect the emitted Parquet footer and fail while the encoder uses hard-coded/default writer layout. + +**Step 2:** Thread the persisted options to `WriterOptions` and add a focused unit test for the conversion. + +**Step 3:** Run the focused E2E and unit test to prove multiple row groups/pages are produced. + +### Task 3: Support future-flush ALTER options and verify + +**Files:** +- Modify: `crates/pg_koldstore/src/hooks/ddl.rs` +- Modify: `crates/pg_koldstore/src/pg_tests/manage.inc.rs` +- Modify: `docs/sql-api.md` + +**Step 1:** Write the failing PostgreSQL test for `ALTER TABLE ... SET` layout options. + +**Step 2:** Validate and persist the values through the existing hook. + +**Step 3:** Run focused tests, formatting, and the relevant workspace checks. diff --git a/docs/quickstart.md b/docs/quickstart.md index a67e710f..c64447cd 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -114,6 +114,10 @@ work that must observe every source commit visible at the start of the call: SELECT koldstore.wait_for_async_mirror(); ``` +The fence covers committed WAL only; it cannot expose the caller's uncommitted +changes. In `REPEATABLE READ` or `SERIALIZABLE`, run it before beginning the +transaction/snapshot that must include those commits. + `flush_table` performs this catch-up automatically. To remove the logical slot and publication, first unmanage every managed table and then run: @@ -145,16 +149,18 @@ LIMIT 3; ## 4. Latest-state mirror (optional inspection) -Management also creates `koldstore.messages__cl`, a latest-state mirror with +Management also creates `koldstore.app_messages__cl` (the source schema and +table name joined with `_`), a latest-state mirror with one metadata row per primary key. It stores the key columns plus `seq` and `op`; it does not duplicate full application row payloads. The mirror follows committed WAL and may briefly lag; the worker and consistency fence close that -gap before flush or any caller-selected strong boundary. Full semantics live -in the [architecture docs](architecture.md). +gap before flush or any caller-selected committed-change boundary. The fence +does not expose uncommitted work. Full semantics live in the +[architecture docs](architecture.md). ```sql SELECT id, seq, op -FROM koldstore.messages__cl +FROM koldstore.app_messages__cl ORDER BY id LIMIT 3; ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index 47ce5b43..76af1140 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -43,7 +43,7 @@ it end-to-end. ## Change API (`changes_since`) Managing a table creates a **latest-state change-log mirror** -(`koldstore.
__cl`): one row per primary key with a monotonic `seq` and +(`koldstore._
__cl`): one row per primary key with a monotonic `seq` and `op` (`INSERT` / `UPDATE` / `DELETE`). Committed WAL is applied by the async mirror worker so flush can cut by `seq` and scans know which keys are still hot. The mirror is not an append-only history of every intermediate update (a @@ -114,9 +114,12 @@ must be **KoldStore-aware** and keep both tiers consistent. - Scoped storage should make per-tenant backup/export a natural subset once cold folders are per `scopeId` -Today: `koldstore.backup_manifest` and validation helpers exist; -`EXPORT TABLE` is the intended archive boundary; `IMPORT TABLE` is still -rejected until those rules land. +Today, the catalog and storage layers contain pieces needed for this design, +but `koldstore.backup_manifest`, cold-storage validation, and packaged +export/import are not shipped SQL interfaces. Their operator surface is tracked +in [#103](https://github.com/kalamdb/koldstore/issues/103). `EXPORT TABLE` is +the intended archive boundary; `IMPORT TABLE` remains rejected until ownership, +conflict, and schema rules land. ## FILE datatype diff --git a/docs/sql-api.md b/docs/sql-api.md index 54fdcd8a..4ade2ac5 100644 --- a/docs/sql-api.md +++ b/docs/sql-api.md @@ -72,7 +72,7 @@ by the normal PostgreSQL reload rules for the chosen scope. | GUC | Type | Default | Meaning | |-----|------|---------|---------| -| `koldstore.user_id` | string | empty | Active user-scope id for user-scoped managed tables. Required for scoped reads and writes. | +| `koldstore.user_id` | string | empty | User-set application scope for user-scoped managed tables. Required for scoped reads and writes; not an authentication credential. | | `koldstore.cold_reads` | string | `auto` | `auto`: cold eligible by catalog/cost; `on`: cold eligible without forcing unnecessary object reads; `off`: hot-only and ERROR when correctness requires cold segments. | | `koldstore.enable_merge_scan` | bool | `on` | Required for managed-table SELECT. When `off`, `KoldMergeScan` errors at execution instead of allowing an incorrect heap-only read. | | `koldstore.explain_pipeline` | bool | `off` | When `on`, `EXPLAIN (FORMAT JSON)` includes the nested `KoldStore Pipeline` diagnostic tree. `EXPLAIN … VERBOSE` also enables it for JSON. Default keeps concise Custom Scan properties plus real `Plans` children. | @@ -80,12 +80,12 @@ by the normal PostgreSQL reload rules for the chosen scope. | `koldstore.max_merge_seen_keys` | int | `1000000` | Per-scan cap on exact PK identities retained by `KoldMergeScan` (fail-closed when exceeded). Protects backends from accidental full-table scans. `0` disables the cap. Clamped to `0..=100000000`. | | `koldstore.log_level` | string | `info` | Extension log verbosity: `error`, `warn`, `info`, `debug`, or `trace`. | | `koldstore.min_max_rows_per_file` | int | `1000` | Minimum allowed `max_rows_per_file` for `manage_table` and flush. Lower temporarily for tests, for example `SET koldstore.min_max_rows_per_file = 100`. Clamped to `1..=1000000`. | -| `koldstore.flush_check_interval_seconds` | int | `30` | How often ephemeral maintenance evaluates `auto_flush` tables, enqueues at most one due flush job, and asks the supervisor to spawn flush executors. Clamped to `1..=86400`. Independent of PostgreSQL autovacuum. | -| `koldstore.max_parallel_flush_jobs` | int | `2` | Max concurrent one-shot flush executor workers per database. Clamped to `1..=16`. Use `1` on small-memory hosts so encode spikes do not overlap. | +| `koldstore.flush_check_interval_seconds` | int | `30` | How often ephemeral maintenance evaluates `auto_flush` tables and enqueues at most one due flush job. The supervisor may then register a one-shot flush executor. Clamped to `1..=86400`. Independent of PostgreSQL autovacuum. Does not by itself fork a flush executor. | +| `koldstore.max_parallel_flush_jobs` | int | `2` | Max concurrent one-shot flush executor workers per database. Clamped to `1..=16`. Cluster cap is 8. Use `1` on small-memory hosts so encode spikes do not overlap. | | `koldstore.flush_job_max_runtime_seconds` | int | `1800` | Wall-clock budget for one flush job attempt. Checked between passes and between streamed batches within a pass (so one oversized force-flush pass cannot outrun the budget); exceeded attempts fail with an error so a stuck worker cannot run forever. `0` disables. Clamped to `0..=86400`. | | `koldstore.flush_execution` | string | `queue` | `queue`: `flush_table` enqueues a durable job and returns its UUID; a one-shot executor runs the work. `inline`: enqueue then run in the calling backend (SPI / `#[pg_test]` only). | | `koldstore.job_retention_days` | int | `30` | Days to retain terminal jobs before purge; `0` disables. Jobs still referenced by pending cold segments are never deleted. | -| `koldstore.async_apply_watchdog_interval_ms` | int | `30000` | Safety watchdog for missed commit wakeups. Managed commits normally set the persistent WAL applier latch immediately. Clamped to `1000..=300000`. | +| `koldstore.async_apply_watchdog_interval_ms` | int | `30000` | Registered GUC (clamped `1000..=300000`). Managed commits `SetLatch` the persistent WAL applier immediately. The applier's idle `WaitLatch` timeout is currently the hardcoded 30 s `WAL_APPLIER_WATCHDOG`; this GUC is not read by the applier loop. | | `koldstore.async_apply_max_rows_per_tick` | int | `0` | Max source row changes per apply tick (`0` = unlimited / drain available WAL). Cap this on small machines (for example `8192`) via `ALTER DATABASE` so background workers see it. | | `koldstore.async_apply_max_ms_per_tick` | int | `0` | Max wall-clock ms per apply tick (`0` = unlimited). When exhausted, commit `applied_lsn` and continue next wake. Cap alongside row budget on small hosts. | | `koldstore.flush_prelock_max_passes` | int | `3` | Max phase-5.5 pre-lock async apply passes during flush before failing closed. | @@ -162,7 +162,11 @@ Optional `check` (default `true`): opens the configured backend (filesystem, S3, GCS, or Azure) and performs a put/delete probe object (`.koldstore-write-probe`) so registration fails fast on bad credentials, unreachable endpoints, or unwritable paths. Filesystem backends also create -`base_path` when needed. Pass `check => false` to skip the probe. +`base_path` when needed and **require the directory to be empty** so existing +files are not mixed with cold objects. Pass `check => false` to skip both the +emptiness requirement and the writability probe (for example when you +intentionally reuse a non-empty directory, or credentials/mounts will exist +later). ```sql SELECT koldstore.register_storage( @@ -171,7 +175,7 @@ SELECT koldstore.register_storage( base_path => '/koldstore-data/cold/', credentials => '{}'::jsonb, config => '{}'::jsonb, - check => false -- skip writability probe + check => false -- skip emptiness + writability probe ); ``` @@ -204,8 +208,9 @@ SELECT koldstore.alter_storage_location( ``` Updates storage location/configuration without direct catalog DML. Optional -`check` (default `true`) probes local filesystem paths the same way as -`register_storage`; pass `check => false` to skip. +`check` (default `true`) probes the new location the same way as +`register_storage` (filesystem roots must be empty; put/delete probe for all +backends). Pass `check => false` to skip. **Returns:** `uuid` — the storage backend id. Errors if the storage name does not exist. @@ -243,7 +248,10 @@ SELECT koldstore.manage_table( min_flush_rows => 1000, max_rows_per_file => 1000, target_file_size_mb => 256, - migration_order_by => 'created_at' + migration_order_by => 'created_at', + parquet_row_group_size => 256, + parquet_data_page_row_count_limit => 64, + parquet_bloom_filter_fpp => 0.01 ); ``` @@ -265,8 +273,16 @@ also available. | `migration_order_by` | `NULL` | Optional oldest-to-newest column used for populated-table migration | | `compression` | `NULL` | Optional Parquet compression name | | `target_file_size_mb` | `NULL` | Optional target Parquet segment size in MiB; stored for future size-aware flushing | +| `parquet_row_group_size` | `NULL` | Rows per Parquet row group on future flushes; omitted keeps the writer default | +| `parquet_data_page_row_count_limit` | `NULL` | Rows per Parquet data page on future flushes; smaller values enable finer page-index pruning | +| `parquet_bloom_filter_fpp` | `NULL` | Bloom filter false-positive probability for future flushes; must be strictly between `0` and `1` | | `auto_flush` | `true` | When `true`, ephemeral maintenance may enqueue flush jobs for this table; set `false` to reserve flushes for cron / manual `flush_table` | +Existing tables can change the same settings for future segments with +`ALTER TABLE ... SET (koldstore_parquet_row_group_size = 256, +koldstore_parquet_data_page_row_count_limit = 64, +koldstore_parquet_bloom_filter_fpp = 0.01)`. Existing segments are unchanged. + **Returns:** `uuid` — the migration job id written to `koldstore.jobs` (empty tables get a completed migrate job; populated tables run mirror initialization inline and return that job id). @@ -335,6 +351,10 @@ SELECT koldstore.unmanage_table( ); ``` +`rehydrate` controls whether cold rows are restored before detaching. The +current implementation accepts but does not execute the planned `drop_cold` +action; do not rely on it to delete or retain objects. + **Returns:** `bigint` — number of `koldstore.schemas` rows deactivated for the table (normally `1` when the table was actively managed, `0` if none were active). @@ -353,7 +373,11 @@ SELECT koldstore.wait_for_async_mirror(); Applies committed source changes available at the fence boundary and returns when the mirror has reached that boundary. The fence LSN is captured at call time (and forced durable), so concurrent writers after that point do not extend -the wait. This is an **optional** strong-consistency API for reads/benchmarks — +the wait. It cannot decode the caller's uncommitted changes, and a fixed +`REPEATABLE READ` or `SERIALIZABLE` snapshot acquired before the call cannot see +state applied afterward. Invoke the fence before acquiring the snapshot that +must include the committed boundary. This is an **optional committed-visibility +API** for reads/benchmarks — `flush_table` and auto-flush do **not** call it (they enqueue/spawn and return). The background worker normally keeps the mirror caught up without an explicit call. @@ -494,6 +518,12 @@ flush/migrate advisory lock to release, deactivates catalog metadata, deletes cold objects under the table prefix, drops the change-log mirror, and records a completed `drop_table_cleanup` job before PostgreSQL removes the heap. +Cold-object deletion currently happens before the surrounding PostgreSQL DDL +transaction commits and cannot be rolled back with it. An aborted `DROP TABLE` +can therefore restore catalog rows whose cold objects are gone; see +[#100](https://github.com/kalamdb/koldstore/issues/100). The `drop_cold` +argument to `unmanage_table` is also not currently executed. + ### `koldstore.table_status` ```sql @@ -703,8 +733,13 @@ extension (tracked: https://github.com/kalamdb/koldstore/issues/56): ## Security User-scoped tables require `koldstore.user_id` and fail closed when it is -missing. RLS/security qualifiers must be enforceable on cold rows or planning -must fail closed. +missing. The GUC is user-settable and must be bound by a trusted connection +layer; it is not authentication. The generated policy is permissive, so another +permissive policy on the same table can broaden the combined RLS expression. +RLS/security qualifiers must be enforceable on cold rows or planning must fail +closed. Until extension-function grants, ownership checks, and definer +`search_path` are hardened, do not expose the management SQL API to untrusted +database roles. ## Upgrade note diff --git a/packaging/debian/copyright b/packaging/debian/copyright new file mode 100644 index 00000000..af24c364 --- /dev/null +++ b/packaging/debian/copyright @@ -0,0 +1,9 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: KoldStore +Source: https://github.com/kalamdb/koldstore + +Files: * +Copyright: 2026 KoldStore contributors +License: Apache-2.0 + On Debian systems, the full text of the Apache License, Version 2.0 is + available in /usr/share/common-licenses/Apache-2.0. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4f0430ea..a7560da5 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.96.0" -components = ["rustfmt", "clippy"] +channel = "nightly" +components = ["rustfmt", "clippy", "rustc-codegen-cranelift-preview"] diff --git a/scripts/build/generate-third-party-notices.sh b/scripts/build/generate-third-party-notices.sh new file mode 100755 index 00000000..d075dbc6 --- /dev/null +++ b/scripts/build/generate-third-party-notices.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Regenerates the license bundle shipped with KoldStore release artifacts. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${ROOT_DIR}" + +if ! command -v cargo-about >/dev/null 2>&1; then + echo "error: cargo-about is required; install cargo-about 0.8.4 first" >&2 + exit 1 +fi + +cargo about -L off generate \ + --locked \ + --fail \ + --manifest-path crates/pg_koldstore/Cargo.toml \ + --no-default-features \ + --features 'pg16 s3' \ + -c about.toml \ + -o THIRD_PARTY_NOTICES.html \ + third_party_licenses.hbs diff --git a/scripts/build/linux.sh b/scripts/build/linux.sh index 91fe51f5..b64bffb1 100755 --- a/scripts/build/linux.sh +++ b/scripts/build/linux.sh @@ -130,9 +130,7 @@ EOF fi bash "${ROOT_DIR}/scripts/ci/apt-get-retry.sh" update -y -qq bash "${ROOT_DIR}/scripts/ci/apt-get-retry.sh" install -y --no-install-recommends ca-certificates curl gnupg - install -d /usr/share/keyrings - curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ - | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg + bash "${ROOT_DIR}/scripts/ci/install-pgdg-key.sh" /usr/share/keyrings/postgresql.gpg echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \ > /etc/apt/sources.list.d/pgdg.list bash "${ROOT_DIR}/scripts/ci/apt-get-retry.sh" update -y -qq @@ -161,6 +159,8 @@ install_rocky_deps() { "https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-${rpm_arch}/pgdg-redhat-repo-latest.noarch.rpm" dnf -qy module disable postgresql || true dnf -y install --allowerasing \ + clang \ + clang-devel \ curl \ gcc \ gcc-c++ \ @@ -173,6 +173,16 @@ install_rocky_deps() { rpm-build \ "postgresql${pg}" \ "postgresql${pg}-devel" + + # pgrx bindgen needs libclang.so (clang-devel). Point clang-sys at %{_libdir} + # so the search does not depend on ldconfig in the container. + if [[ -z "${LIBCLANG_PATH:-}" ]]; then + local libdir + libdir="$(rpm --eval '%{_libdir}')" + if compgen -G "${libdir}/libclang.so*" >/dev/null; then + export LIBCLANG_PATH="${libdir}" + fi + fi } install_linux_build_deps() { diff --git a/scripts/build/release-common.sh b/scripts/build/release-common.sh index fc560643..d8bc2d3a 100755 --- a/scripts/build/release-common.sh +++ b/scripts/build/release-common.sh @@ -177,11 +177,19 @@ discover_rpm_file_entries() { stage_release_tree() { local pg="$1" local stage_dir="$2" - local root + local root project_root root="$(pgrx_package_root "${pg}")" + project_root="$(build_release_root_dir)" rm -rf "${stage_dir}" mkdir -p "${stage_dir}" cp -a "${root}/." "${stage_dir}/" + for notice in LICENSE NOTICE THIRD_PARTY_NOTICES.html; do + if [[ ! -f "${project_root}/${notice}" ]]; then + echo "error: required release notice missing: ${project_root}/${notice}" >&2 + return 1 + fi + install -m 0644 "${project_root}/${notice}" "${stage_dir}/${notice}" + done write_install_sh "${stage_dir}" } @@ -211,9 +219,15 @@ create_deb_package() { local dist_dir="dist/${version}" local out out="$(artifact_basename "${version}" "${pg}" "${distro}" "${arch}" "deb")" - local deb_root + local deb_root project_root doc_dir deb_root="$(mktemp -d)" + project_root="$(build_release_root_dir)" cp -a "${stage_dir}/usr" "${deb_root}/" + doc_dir="${deb_root}/usr/share/doc/postgresql-${pg}-koldstore" + install -D -m 0644 "${project_root}/packaging/debian/copyright" "${doc_dir}/copyright" + install -m 0644 "${stage_dir}/LICENSE" "${doc_dir}/LICENSE" + install -m 0644 "${stage_dir}/NOTICE" "${doc_dir}/NOTICE" + install -m 0644 "${stage_dir}/THIRD_PARTY_NOTICES.html" "${doc_dir}/THIRD_PARTY_NOTICES.html" mkdir -p "${deb_root}/DEBIAN" cat >"${deb_root}/DEBIAN/control" </dev/null diff --git a/scripts/ci/install-pgdg-key.sh b/scripts/ci/install-pgdg-key.sh new file mode 100755 index 00000000..07c9ef84 --- /dev/null +++ b/scripts/ci/install-pgdg-key.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Install the PGDG apt repository signing key. +# +# Prefers the vendored key in this repo (avoids flaky www.postgresql.org +# connectivity on CI runners), then falls back to a few HTTPS mirrors with +# retries. +# +# Usage: +# scripts/ci/install-pgdg-key.sh /usr/share/keyrings/postgresql.gpg +# scripts/ci/install-pgdg-key.sh /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg +# +# Writes a binary keyring (gpg --dearmor) to the destination path. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VENDORED_KEY="${ROOT_DIR}/scripts/ci/keys/ACCC4CF8.asc" + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +DEST="$1" +DEST_DIR="$(dirname "${DEST}")" + +run_as_root() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + else + sudo "$@" + fi +} + +dearmor_to_dest() { + local src="$1" + run_as_root install -d "${DEST_DIR}" + # Write via temp + mv so a failed dearmor cannot leave a truncated keyring. + local tmp + tmp="$(mktemp)" + # shellcheck disable=SC2064 + trap 'rm -f "'"${tmp}"'"' RETURN + if ! gpg --batch --yes --dearmor -o "${tmp}" "${src}"; then + return 1 + fi + if [[ ! -s "${tmp}" ]]; then + echo "error: dearmored PGDG keyring is empty" >&2 + return 1 + fi + run_as_root install -m 0644 "${tmp}" "${DEST}" +} + +fetch_url_to() { + local url="$1" + local out="$2" + curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 2 \ + --retry-all-errors -o "${out}" "${url}" +} + +if [[ -f "${VENDORED_KEY}" ]]; then + echo "==> installing vendored PGDG key (${VENDORED_KEY})" + dearmor_to_dest "${VENDORED_KEY}" + exit 0 +fi + +echo "warning: vendored PGDG key missing at ${VENDORED_KEY}; fetching over HTTPS" >&2 + +URLS=( + "https://www.postgresql.org/media/keys/ACCC4CF8.asc" + "https://apt.postgresql.org/pub/repos/apt/ACCC4CF8.asc" +) + +TMP_ASC="$(mktemp)" +# shellcheck disable=SC2064 +trap 'rm -f "'"${TMP_ASC}"'"' EXIT + +ok=0 +for url in "${URLS[@]}"; do + echo "==> fetching PGDG key from ${url}" + if fetch_url_to "${url}" "${TMP_ASC}" && [[ -s "${TMP_ASC}" ]]; then + if dearmor_to_dest "${TMP_ASC}"; then + ok=1 + break + fi + else + echo "warning: failed to download ${url}" >&2 + fi +done + +if (( ok != 1 )); then + echo "error: could not install PGDG apt signing key from vendored copy or HTTPS mirrors" >&2 + exit 1 +fi diff --git a/scripts/ci/keys/ACCC4CF8.asc b/scripts/ci/keys/ACCC4CF8.asc new file mode 100644 index 00000000..8480576e --- /dev/null +++ b/scripts/ci/keys/ACCC4CF8.asc @@ -0,0 +1,77 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBE6XR8IBEACVdDKT2HEH1IyHzXkb4nIWAY7echjRxo7MTcj4vbXAyBKOfjja +UrBEJWHN6fjKJXOYWXHLIYg0hOGeW9qcSiaa1/rYIbOzjfGfhE4x0Y+NJHS1db0V +G6GUj3qXaeyqIJGS2z7m0Thy4Lgr/LpZlZ78Nf1fliSzBlMo1sV7PpP/7zUO+aA4 +bKa8Rio3weMXQOZgclzgeSdqtwKnyKTQdXY5MkH1QXyFIk1nTfWwyqpJjHlgtwMi +c2cxjqG5nnV9rIYlTTjYG6RBglq0SmzF/raBnF4Lwjxq4qRqvRllBXdFu5+2pMfC +IZ10HPRdqDCTN60DUix+BTzBUT30NzaLhZbOMT5RvQtvTVgWpeIn20i2NrPWNCUh +hj490dKDLpK/v+A5/i8zPvN4c6MkDHi1FZfaoz3863dylUBR3Ip26oM0hHXf4/2U +A/oA4pCl2W0hc4aNtozjKHkVjRx5Q8/hVYu+39csFWxo6YSB/KgIEw+0W8DiTII3 +RQj/OlD68ZDmGLyQPiJvaEtY9fDrcSpI0Esm0i4sjkNbuuh0Cvwwwqo5EF1zfkVj +Tqz2REYQGMJGc5LUbIpk5sMHo1HWV038TWxlDRwtOdzw08zQA6BeWe9FOokRPeR2 +AqhyaJJwOZJodKZ76S+LDwFkTLzEKnYPCzkoRwLrEdNt1M7wQBThnC5z6wARAQAB +tBxQb3N0Z3JlU1FMIERlYmlhbiBSZXBvc2l0b3J5iQJOBBMBCAA4AhsDBQsJCAcD +BRUKCQgLBRYCAwEAAh4BAheAFiEEuXsK/KoaR/BE8kSgf8x9RqzMTPgFAlhtCD8A +CgkQf8x9RqzMTPgECxAAk8uL+dwveTv6eH21tIHcltt8U3Ofajdo+D/ayO53LiYO +xi27kdHD0zvFMUWXLGxQtWyeqqDRvDagfWglHucIcaLxoxNwL8+e+9hVFIEskQAY +kVToBCKMXTQDLarz8/J030Pmcv3ihbwB+jhnykMuyyNmht4kq0CNgnlcMCdVz0d3 +z/09puryIHJrD+A8y3TD4RM74snQuwc9u5bsckvRtRJKbP3GX5JaFZAqUyZNRJRJ +Tn2OQRBhCpxhlZ2afkAPFIq2aVnEt/Ie6tmeRCzsW3lOxEH2K7MQSfSu/kRz7ELf +Cz3NJHj7rMzC+76Rhsas60t9CjmvMuGONEpctijDWONLCuch3Pdj6XpC+MVxpgBy +2VUdkunb48YhXNW0jgFGM/BFRj+dMQOUbY8PjJjsmVV0joDruWATQG/M4C7O8iU0 +B7o6yVv4m8LDEN9CiR6r7H17m4xZseT3f+0QpMe7iQjz6XxTUFRQxXqzmNnloA1T +7VjwPqIIzkj/u0V8nICG/ktLzp1OsCFatWXh7LbU+hwYl6gsFH/mFDqVxJ3+DKQi +vyf1NatzEwl62foVjGUSpvh3ymtmtUQ4JUkNDsXiRBWczaiGSuzD9Qi0ONdkAX3b +ewqmN4TfE+XIpCPxxHXwGq9Rv1IFjOdCX0iG436GHyTLC1tTUIKF5xV4Y0+cXIOI +RgQQEQgABgUCTpdI7gAKCRDFr3dKWFELWqaPAKD1TtT5c3sZz92Fj97KYmqbNQZP ++ACfSC6+hfvlj4GxmUjp1aepoVTo3weJAhwEEAEIAAYFAk6XSQsACgkQTFprqxLS +p64F8Q//cCcutwrH50UoRFejg0EIZav6LUKejC6kpLeubbEtuaIH3r2zMblPGc4i ++eMQKo/PqyQrceRXeNNlqO6/exHozYi2meudxa6IudhwJIOn1MQykJbNMSC2sGUp +1W5M1N5EYgt4hy+qhlfnD66LR4G+9t5FscTJSy84SdiOuqgCOpQmPkVRm1HX5X1+ +dmnzMOCk5LHHQuiacV0qeGO7JcBCVEIDr+uhU1H2u5GPFNHm5u15n25tOxVivb94 +xg6NDjouECBH7cCVuW79YcExH/0X3/9G45rjdHlKPH1OIUJiiX47OTxdG3dAbB4Q +fnViRJhjehFscFvYWSqXo3pgWqUsEvv9qJac2ZEMSz9x2mj0ekWxuM6/hGWxJdB+ ++985rIelPmc7VRAXOjIxWknrXnPCZAMlPlDLu6+vZ5BhFX0Be3y38f7GNCxFkJzl +hWZ4Cj3WojMj+0DaC1eKTj3rJ7OJlt9S9xnO7OOPEUTGyzgNIDAyCiu8F4huLPaT +ape6RupxOMHZeoCVlqx3ouWctelB2oNXcxxiQ/8y+21aHfD4n/CiIFwDvIQjl7dg +mT3u5Lr6yxuosR3QJx1P6rP5ZrDTP9khT30t+HZCbvs5Pq+v/9m6XDmi+NlU7Zuh +Ehy97tL3uBDgoL4b/5BpFL5U9nruPlQzGq1P9jj40dxAaDAX/WKJAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlB5KywFCQPDFt8ACgkQf8x9RqzM +TPhuCQ//QAjRSAOCQ02qmUAikT+mTB6baOAakkYq6uHbEO7qPZkv4E/M+HPIJ4wd +nBNeSQjfvdNcZBA/x0hr5EMcBneKKPDj4hJ0panOIRQmNSTThQw9OU351gm3YQct +AMPRUu1fTJAL/AuZUQf9ESmhyVtWNlH/56HBfYjE4iVeaRkkNLJyX3vkWdJSMwC/ +LO3Lw/0M3R8itDsm74F8w4xOdSQ52nSRFRh7PunFtREl+QzQ3EA/WB4AIj3VohIG +kWDfPFCzV3cyZQiEnjAe9gG5pHsXHUWQsDFZ12t784JgkGyO5wT26pzTiuApWM3k +/9V+o3HJSgH5hn7wuTi3TelEFwP1fNzI5iUUtZdtxbFOfWMnZAypEhaLmXNkg4zD +kH44r0ss9fR0DAgUav1a25UnbOn4PgIEQy2fgHKHwRpCy20d6oCSlmgyWsR40EPP +YvtGq49A2aK6ibXmdvvFT+Ts8Z+q2SkFpoYFX20mR2nsF0fbt1lfH65P64dukxeR +GteWIeNakDD40bAAOH8+OaoTGVBJ2ACJfLVNM53PEoftavAwUYMrR910qvwYfd/4 +6rh46g1Frr9SFMKYE9uvIJIgDsQB3QBp71houU4H55M5GD8XURYs+bfiQpJG1p7e +B8e5jZx1SagNWc4XwL2FzQ9svrkbg1Y+359buUiP7T6QXX2zY++JAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlEqbZUFCQg2wEEACgkQf8x9RqzM +TPhFMQ//WxAfKMdpSIA9oIC/yPD/dJpY/+DyouOljpE6MucMy/ArBECjFTBwi/j9 +NYM4ynAk34IkhuNexc1i9/05f5RM6+riLCLgAOsADDbHD4miZzoSxiVr6GQ3YXMb +OGld9kV9Sy6mGNjcUov7iFcf5Hy5w3AjPfKuR9zXswyfzIU1YXObiiZT38l55pp/ +BSgvGVQsvbNjsff5CbEKXS7q3xW+WzN0QWF6YsfNVhFjRGj8hKtHvwKcA02wwjLe +LXVTm6915ZUKhZXUFc0vM4Pj4EgNswH8Ojw9AJaKWJIZmLyW+aP+wpu6YwVCicxB +Y59CzBO2pPJDfKFQzUtrErk9irXeuCCLesDyirxJhv8o0JAvmnMAKOLhNFUrSQ2m ++3EnF7zhfz70gHW+EG8X8mL/EN3/dUM09j6TVrjtw43RLxBzwMDeariFF9yC+5bL +tnGgxjsB9Ik6GV5v34/NEEGf1qBiAzFmDVFRZlrNDkq6gmpvGnA5hUWNr+y0i01L +jGyaLSWHYjgw2UEQOqcUtTFK9MNzbZze4mVaHMEz9/aMfX25R6qbiNqCChveIm8m +Yr5Ds2zdZx+G5bAKdzX7nx2IUAxFQJEE94VLSp3npAaTWv3sHr7dR8tSyUJ9poDw +gw4W9BIcnAM7zvFYbLF5FNggg/26njHCCN70sHt8zGxKQINMc6SJAj0EEwEIACcC +GwMFCwkIBwMFFQoJCAsFFgIDAQACHgECF4AFAlLpFRkFCQ6EJy0ACgkQf8x9RqzM +TPjOZA//Zp0e25pcvle7cLc0YuFr9pBv2JIkLzPm83nkcwKmxaWayUIG4Sv6pH6h +m8+S/CHQij/yFCX+o3ngMw2J9HBUvafZ4bnbI0RGJ70GsAwraQ0VlkIfg7GUw3Tz +voGYO42rZTru9S0K/6nFP6D1HUu+U+AsJONLeb6oypQgInfXQExPZyliUnHdipei +4WR1YFW6sjSkZT/5C3J1wkAvPl5lvOVthI9Zs6bZlJLZwusKxU0UM4Btgu1Sf3nn +JcHmzisixwS9PMHE+AgPWIGSec/N27a0KmTTvImV6K6nEjXJey0K2+EYJuIBsYUN +orOGBwDFIhfRk9qGlpgt0KRyguV+AP5qvgry95IrYtrOuE7307SidEbSnvO5ezNe +mE7gT9Z1tM7IMPfmoKph4BfpNoH7aXiQh1Wo+ChdP92hZUtQrY2Nm13cmkxYjQ4Z +gMWfYMC+DA/GooSgZM5i6hYqyyfAuUD9kwRN6BqTbuAUAp+hCWYeN4D88sLYpFh3 +paDYNKJ+Gf7Yyi6gThcV956RUFDH3ys5Dk0vDL9NiWwdebWfRFbzoRM3dyGP889a +OyLzS3mh6nHzZrNGhW73kslSQek8tjKrB+56hXOnb4HaElTZGDvD5wmrrhN94kby +Gtz3cydIohvNO9d90+29h0eGEDYti7j7maHkBKUAwlcPvMg5m3Y= +=DA1T +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/tests/test_release_licenses.sh b/scripts/tests/test_release_licenses.sh new file mode 100755 index 00000000..55859af0 --- /dev/null +++ b/scripts/tests/test_release_licenses.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Verifies release staging carries the legal notices required by every artifact. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${ROOT_DIR}" +# shellcheck source=scripts/build/release-common.sh +source "${ROOT_DIR}/scripts/build/release-common.sh" + +test_root="$(mktemp -d)" +stage_dir="${test_root}/stage" +trap 'rm -rf "${test_root}"' EXIT + +CARGO_PROFILE="test-release-license" +package_root="$(pgrx_package_root 16)" +mkdir -p "${package_root}/usr/lib/postgresql/16/lib" \ + "${package_root}/usr/share/postgresql/16/extension" +touch "${package_root}/usr/lib/postgresql/16/lib/koldstore.so" \ + "${package_root}/usr/share/postgresql/16/extension/koldstore.control" \ + "${package_root}/usr/share/postgresql/16/extension/koldstore--0.1.0.sql" +trap 'rm -rf "${test_root}" "${package_root}"' EXIT + +stage_release_tree 16 "${stage_dir}" + +test -f "${stage_dir}/LICENSE" +test -f "${stage_dir}/NOTICE" +test -f "${stage_dir}/THIRD_PARTY_NOTICES.html" +test -x "${stage_dir}/install.sh" + +create_tarball_package 0.0.0-license-test 16 test amd64 "${stage_dir}" +archive="dist/0.0.0-license-test/pg_koldstore-v0.0.0-license-test-pg16-test-amd64.tar.gz" +trap 'rm -rf "${test_root}" "${package_root}" "dist/0.0.0-license-test"' EXIT +tar -tzf "${archive}" | grep -E '/LICENSE$' +tar -tzf "${archive}" | grep -E '/NOTICE$' +tar -tzf "${archive}" | grep -E '/THIRD_PARTY_NOTICES.html$' diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 917ece29..53cff0d3 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -25,6 +25,7 @@ koldstore-manifest.workspace = true koldstore-parquet.workspace = true koldstore-storage = { workspace = true, features = ["s3"] } koldstore-supervisor.workspace = true +koldstore-wal-mirror.workspace = true koldstore-memory-tests = { path = "../memory" } parquet.workspace = true koldstore = { path = "../../crates/pg_koldstore", package = "pg_koldstore", default-features = false } diff --git a/tests/e2e/common/assertions.rs b/tests/e2e/common/assertions.rs index a682df16..b77b5bb9 100644 --- a/tests/e2e/common/assertions.rs +++ b/tests/e2e/common/assertions.rs @@ -166,9 +166,8 @@ pub fn assert_kold_merge_scan_cold_reads( /// Asserts an analyzed merge scan reports executed cold catalog and parquet reads. /// -/// Timing lines are intentionally omitted: e2e `explain_analyze` uses -/// `EXPLAIN (ANALYZE, TIMING OFF)`, matching native PostgreSQL and the -/// `#[pg_test]` contract that TIMING OFF suppresses custom phase clocks. +/// Compact TEXT `EXPLAIN ANALYZE` (including `TIMING OFF`) exposes aggregate cold +/// counters. Per-segment `Footer First` / `Bloom:` detail remains VERBOSE-only. pub fn assert_kold_merge_scan_executed_cold_reads( plan: &str, min_parquet_segments: usize, @@ -177,7 +176,7 @@ pub fn assert_kold_merge_scan_executed_cold_reads( anyhow::ensure!( plan.lines().any(|line| { let trimmed = line.trim_start(); - trimmed.starts_with("Status:") && trimmed.contains("executed") + trimmed == "Status: executed" || trimmed.starts_with("Status: executed ") }), "expected executed cold scan status in analyzed plan, got:\n{plan}" ); @@ -189,15 +188,19 @@ pub fn assert_kold_merge_scan_executed_cold_reads( ); anyhow::ensure!( plan.lines() - .any(|line| line.contains("Footer First") && line.contains("true")), - "expected footer-first Parquet I/O details in analyzed plan, got:\n{plan}" + .any(|line| line.trim_start().starts_with("Bytes Fetched:")), + "expected Bytes Fetched aggregate in analyzed plan, got:\n{plan}" ); anyhow::ensure!( - plan.lines().any(|line| line.contains("Row Groups Total")), + plan.lines().any(|line| { + let trimmed = line.trim_start(); + trimmed.starts_with("Row Groups Total:") || trimmed.starts_with("Row Groups Read:") + }), "expected row-group prune details in analyzed plan, got:\n{plan}" ); anyhow::ensure!( - plan.lines().any(|line| line.contains("Bloom:")), + plan.lines() + .any(|line| { line.contains("Segments Pruned by Bloom") || line.contains("Bloom:") }), "expected bloom prune details in analyzed plan, got:\n{plan}" ); anyhow::ensure!( diff --git a/tests/e2e/common/async_mirror.rs b/tests/e2e/common/async_mirror.rs index d6ed0675..dee87d14 100644 --- a/tests/e2e/common/async_mirror.rs +++ b/tests/e2e/common/async_mirror.rs @@ -7,6 +7,198 @@ const WORKER_START_DEADLINE: Duration = Duration::from_secs(30); const BACKGROUND_APPLY_DEADLINE: Duration = Duration::from_secs(30); const WORKER_OBSERVE_DEADLINE: Duration = Duration::from_secs(2); +/// Fully observational WAL/flush state used by autonomous liveness tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PassiveKoldStoreState { + pub wal_pid: Option, + pub wal_required: bool, + pub wal_pending: bool, + pub wal_generation: i64, + pub wal_processed_generation: i64, + pub completed_jobs: i64, + pub active_jobs: i64, + pub error_jobs: i64, + pub last_error: Option, + pub hot_rows: i64, +} + +async fn passive_koldstore_state( + client: &tokio_postgres::Client, + relation: &str, +) -> Result { + let row = client + .query_one( + r#" + WITH worker AS ( + SELECT koldstore.async_mirror_status() AS status + ), + jobs AS ( + SELECT + count(*) FILTER (WHERE status = 'completed')::bigint AS completed_jobs, + count(*) FILTER (WHERE status IN ('pending', 'running'))::bigint AS active_jobs, + count(*) FILTER (WHERE status = 'error')::bigint AS error_jobs, + max(error_trace) FILTER (WHERE status = 'error') AS last_error + FROM koldstore.jobs + WHERE table_oid = $1::text::regclass::oid + AND job_type = 'flush' + AND created_at >= COALESCE( + ( + SELECT created_at + FROM koldstore.schemas + WHERE table_oid = $1::text::regclass::oid AND active + ), + '-infinity'::timestamptz + ) + ) + SELECT + (worker.status->'wal_applier'->>'pid')::integer, + COALESCE((worker.status->'wal_applier'->>'required')::boolean, false), + COALESCE((worker.status->'wal_applier'->>'pending')::boolean, false), + COALESCE((worker.status->'wal_applier'->>'wal_generation')::bigint, 0), + COALESCE((worker.status->'wal_applier'->>'wal_processed_generation')::bigint, 0), + jobs.completed_jobs, + jobs.active_jobs, + jobs.error_jobs, + jobs.last_error + FROM worker CROSS JOIN jobs + "#, + &[&relation], + ) + .await?; + + Ok(PassiveKoldStoreState { + wal_pid: row.get(0), + wal_required: row.get(1), + wal_pending: row.get(2), + wal_generation: row.get(3), + wal_processed_generation: row.get(4), + completed_jobs: row.get(5), + active_jobs: row.get(6), + error_jobs: row.get(7), + last_error: row.get(8), + hot_rows: super::sql::hot_row_count(client, relation).await?, + }) +} + +/// Passively waits for WAL acknowledgement and automatic flush convergence. +/// +/// This oracle must remain observational: it intentionally does not ensure, +/// fence, tick, flush, restart, or recover the subsystem under test. +/// +/// # Errors +/// +/// Returns an error when state probes fail or the deadline expires. +pub async fn wait_for_passive_convergence( + client: &tokio_postgres::Client, + relation: &str, + target_lsn: &str, + minimum_completed_jobs: i64, + hot_row_limit: i64, + deadline: Duration, +) -> Result { + let started = Instant::now(); + loop { + let state = passive_koldstore_state(client, relation).await?; + let progress = async_mirror_progress(client).await?; + let slot_reached_target: bool = client + .query_one( + "SELECT $1::text::pg_lsn >= $2::text::pg_lsn", + &[&progress.confirmed_flush_lsn, &target_lsn], + ) + .await? + .get(0); + let wal_caught_up = state.wal_required + && state.wal_pid.is_some_and(|pid| pid > 0) + && !state.wal_pending + && state.wal_generation == state.wal_processed_generation + && slot_reached_target; + // Slot/apply-lock finalize races are retryable under concurrent WAL apply: + // a later completed job can still drain hot. Require no active work and + // hot under limit; do not demand a historically empty error column. + let flush_caught_up = state.completed_jobs >= minimum_completed_jobs + && state.active_jobs == 0 + && state.hot_rows <= hot_row_limit + && error_jobs_are_acceptable(&state); + if wal_caught_up && flush_caught_up { + return Ok(state); + } + if let Some(reason) = non_retryable_flush_failure(&state, hot_row_limit) { + anyhow::bail!( + "passive convergence saw a non-retryable flush failure; relation={relation}, \ + target_lsn={target_lsn}, confirmed_flush_lsn={}, reason={reason}, state={state:?}", + progress.confirmed_flush_lsn + ); + } + anyhow::ensure!( + started.elapsed() <= deadline, + "passive convergence timed out after {deadline:?}; relation={relation}, \ + target_lsn={target_lsn}, confirmed_flush_lsn={}, state={state:?}", + progress.confirmed_flush_lsn + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// Slot/apply-lock finalize failures are expected under concurrent apply and are +/// retried by a later auto-flush job. Other terminal errors are not acceptable +/// once the queue is idle and hot is still above the limit. +fn error_jobs_are_acceptable(state: &PassiveKoldStoreState) -> bool { + if state.error_jobs == 0 { + return true; + } + match state.last_error.as_deref() { + Some(error) => is_retryable_flush_error(error), + None => false, + } +} + +fn non_retryable_flush_failure( + state: &PassiveKoldStoreState, + hot_row_limit: i64, +) -> Option<&'static str> { + if state.error_jobs == 0 || state.active_jobs != 0 || state.hot_rows <= hot_row_limit { + return None; + } + match state.last_error.as_deref() { + Some(error) if is_retryable_flush_error(error) => None, + Some(_) => Some("terminal flush error with hot still above limit"), + None => Some("error job without error_trace"), + } +} + +fn is_retryable_flush_error(error: &str) -> bool { + error.contains("slot lock") || error.contains("apply lock") +} + +/// Passively waits for the persistent WAL applier to appear. +/// +/// # Errors +/// +/// Returns an error when the status probe fails or the deadline expires. +pub async fn wait_for_wal_applier_passively( + client: &tokio_postgres::Client, + deadline: Duration, +) -> Result { + let started = Instant::now(); + loop { + let pid: Option = client + .query_one( + "SELECT (koldstore.async_mirror_status()->'wal_applier'->>'pid')::integer", + &[], + ) + .await? + .get(0); + if let Some(pid) = pid.filter(|pid| *pid > 0) { + return Ok(pid); + } + anyhow::ensure!( + started.elapsed() <= deadline, + "WAL applier did not appear passively within {deadline:?}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct AsyncWorkerState { slot_present: bool, @@ -134,6 +326,18 @@ pub async fn wait_for_async_worker_auto_restart( } } +/// Published WAL-applier PID for the current database. +/// +/// # Errors +/// +/// Returns an error when status probing fails or no live PID is published. +pub async fn wal_applier_pid(client: &tokio_postgres::Client) -> Result { + wal_process_pid(client) + .await? + .filter(|pid| *pid > 0) + .ok_or_else(|| anyhow::anyhow!("persistent WAL applier has no published PID")) +} + /// Returns whether the persistent WAL-applier service is running or starting for /// the current database. /// diff --git a/tests/e2e/common/catalog.rs b/tests/e2e/common/catalog.rs index df5d2af4..a7153e50 100644 --- a/tests/e2e/common/catalog.rs +++ b/tests/e2e/common/catalog.rs @@ -32,14 +32,46 @@ pub async fn assert_system_columns_absent(client: &Client, relation: &str) -> Re Ok(()) } -/// Returns the default `koldstore.
__cl` mirror relation for a source table. +/// Returns the default schema-qualified mirror relation for a source table. #[must_use] pub fn change_log_mirror_relation(source_relation: &str) -> String { - let relation = source_relation - .split_once('.') - .map(|(_, name)| name) - .unwrap_or(source_relation); - format!("koldstore.{relation}__cl") + let source = koldstore_common::TableName::parse(source_relation) + .expect("e2e source relation must be a safe one- or two-part identifier"); + koldstore_wal_mirror::mirror_relation_for_source(&source) + .expect("e2e source relation must produce a mirror") + .table_name() + .as_str() + .to_string() +} + +/// Returns the unqualified mirror relation name for a source table. +#[must_use] +pub fn change_log_mirror_relation_name(source_relation: &str) -> String { + let mirror = change_log_mirror_relation(source_relation); + koldstore_common::TableName::parse(&mirror) + .expect("generated mirror relation must parse") + .relation() + .to_string() +} + +/// Returns the PK-update guard trigger name installed on a managed source table. +#[must_use] +pub fn change_log_pk_guard_trigger_name(source_relation: &str) -> String { + koldstore_wal_mirror::pk_guard_trigger_name(&change_log_mirror_relation_name(source_relation)) +} + +/// Returns the mirror seq index name for a source table. +#[must_use] +pub fn change_log_mirror_seq_index_name(source_relation: &str) -> String { + koldstore_wal_mirror::mirror_seq_index_name(&change_log_mirror_relation_name(source_relation)) +} + +/// Returns the mirror tombstone seq index name for a source table. +#[must_use] +pub fn change_log_mirror_tombstone_index_name(source_relation: &str) -> String { + koldstore_wal_mirror::mirror_tombstone_index_name(&change_log_mirror_relation_name( + source_relation, + )) } /// Asserts that the table-specific change-log mirror relation exists. diff --git a/tests/e2e/common/cluster.rs b/tests/e2e/common/cluster.rs index 761973a9..79dde215 100644 --- a/tests/e2e/common/cluster.rs +++ b/tests/e2e/common/cluster.rs @@ -405,7 +405,8 @@ async fn sync_koldstore_extension_sql(client: &Client) -> Result<()> { } // Dropping the extension while leftover managed-table triggers/workers exist - // can crash the backend (shared_preload). Quiesce them first. + // can crash the backend (shared_preload). Quiesce them first and keep the + // supervisor from respawning the WAL applier into a DROP deadlock. let _ = client .batch_execute( r#" @@ -413,7 +414,12 @@ async fn sync_koldstore_extension_sql(client: &Client) -> Result<()> { "#, ) .await; - let _ = super::async_mirror::terminate_async_worker(client).await; + if super::async_mirror::force_stop_async_worker(client) + .await + .is_err() + { + let _ = super::async_mirror::terminate_async_worker(client).await; + } match client .batch_execute( @@ -424,8 +430,12 @@ async fn sync_koldstore_extension_sql(client: &Client) -> Result<()> { ) .await { - Ok(()) => Ok(()), + Ok(()) => { + let _ = super::async_mirror::release_async_worker_stop_lock(client).await; + Ok(()) + } Err(error) => { + let _ = super::async_mirror::release_async_worker_stop_lock(client).await; if error_chain_contains(&error, "connection closed") || error_chain_contains(&error, "terminating connection") || error_chain_contains(&error, "server closed the connection") diff --git a/tests/e2e/common/db.rs b/tests/e2e/common/db.rs index 898da360..943c13fe 100644 --- a/tests/e2e/common/db.rs +++ b/tests/e2e/common/db.rs @@ -259,10 +259,7 @@ impl TestDb { catalog::assert_system_columns_absent(&self.client, relation).await?; catalog::assert_change_log_mirror_exists( &self.client, - &format!( - "koldstore.{}__cl", - relation.rsplit('.').next().unwrap_or(relation) - ), + &catalog::change_log_mirror_relation(relation), ) .await?; catalog::assert_catalog_has_active_schema(&self.client, relation).await?; @@ -293,10 +290,7 @@ impl TestDb { catalog::assert_system_columns_absent(&self.client, relation).await?; catalog::assert_change_log_mirror_exists( &self.client, - &format!( - "koldstore.{}__cl", - relation.rsplit('.').next().unwrap_or(relation) - ), + &catalog::change_log_mirror_relation(relation), ) .await?; catalog::assert_catalog_has_active_schema(&self.client, relation).await?; diff --git a/tests/e2e/common/flush_executor.rs b/tests/e2e/common/flush_executor.rs index 30470de4..656412ea 100644 --- a/tests/e2e/common/flush_executor.rs +++ b/tests/e2e/common/flush_executor.rs @@ -23,17 +23,27 @@ pub fn flush_executor_backend_type(database_oid: u32) -> String { /// Returns live flush executor PIDs for the current database. /// +/// Matches `backend_type` (which embeds the database OID) rather than +/// `datname`, because PostgreSQL 18 can report NULL `datid` until +/// `pgstat_bestart`. +/// /// # Errors /// /// Returns an error when the activity probe fails. pub async fn flush_executor_pids(client: &Client) -> Result> { + let oid: i64 = client + .query_one( + "SELECT oid::bigint FROM pg_catalog.pg_database WHERE datname = current_database()", + &[], + ) + .await + .context("resolve database oid for flush executor probe")? + .get(0); + let backend_type = flush_executor_backend_type(u32::try_from(oid).unwrap_or(0)); let rows = client .query( - "SELECT a.pid::int4 \ - FROM pg_catalog.pg_stat_activity a \ - WHERE a.datname = current_database() \ - AND a.backend_type LIKE $1", - &[&format!("{FLUSH_EXECUTOR_BACKEND_PREFIX}%")], + "SELECT a.pid::int4 FROM pg_catalog.pg_stat_activity a WHERE a.backend_type = $1", + &[&backend_type], ) .await .context("list flush executor pids")?; diff --git a/tests/e2e/common/memory.rs b/tests/e2e/common/memory.rs index f957e466..5c135d5d 100644 --- a/tests/e2e/common/memory.rs +++ b/tests/e2e/common/memory.rs @@ -58,6 +58,54 @@ impl Default for SpikeBudget { } } +/// Per-process startup and RSS bounds for WAL appliers and flush executors. +/// +/// Cluster-wide spike gates in [`SpikeBudget`] still apply; these bounds are +/// the lightweight-launcher contract: a quiet WAL backend looks like a client +/// backend, and a default-size flush executor must not dominate the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WorkerFootprintBudget { + /// Max time for the supervisor to replace a killed WAL applier. + pub wal_startup: std::time::Duration, + /// Max idle WAL-applier RSS (includes mapped `shared_buffers` in RSS). + pub wal_idle_rss_max_bytes: u64, + /// Idle WAL RSS may exceed a sibling client backend by this slack. + pub wal_idle_rss_slack_bytes: u64, + /// Max time from queue `flush_table` until a flush executor PID is visible + /// (or the job has already finished). + pub flush_startup: std::time::Duration, + /// Max RSS of one flush executor during a default-size encode (`max_rows_per_file` 1000). + pub flush_executor_rss_max_bytes: u64, + /// Max PK `SELECT` / `SELECT 1` latency on a concurrent session while flush runs. + pub concurrent_select_max: std::time::Duration, + /// Max small `INSERT` latency on a concurrent session while flush runs. + pub concurrent_insert_max: std::time::Duration, +} + +impl Default for WorkerFootprintBudget { + fn default() -> Self { + Self { + // Supervisor child-lifecycle grace is 1s; fork + SPI connect adds more. + wal_startup: std::time::Duration::from_secs(5), + wal_idle_rss_max_bytes: 256 * 1024 * 1024, + wal_idle_rss_slack_bytes: 64 * 1024 * 1024, + flush_startup: std::time::Duration::from_secs(5), + flush_executor_rss_max_bytes: 256 * 1024 * 1024, + concurrent_select_max: std::time::Duration::from_millis(2_000), + concurrent_insert_max: std::time::Duration::from_millis(2_000), + } + } +} + +/// Reads RSS for one PID (`/proc` on Linux, `ps` on macOS). +/// +/// # Errors +/// +/// Returns an error when the process cannot be sampled. +pub fn pid_rss_bytes(pid: i32) -> Result { + process_rss_bytes(pid).map_err(|error| anyhow::anyhow!("{error}")) +} + /// Captures PostgreSQL memory-context totals and RSS for the current backend /// plus workers whose command line contains the cluster port. /// @@ -190,6 +238,34 @@ pub fn spike_budget_from_env() -> SpikeBudget { budget } +/// Loads per-process WAL/flush footprint budgets from the environment when set. +#[must_use] +pub fn worker_footprint_budget_from_env() -> WorkerFootprintBudget { + let mut budget = WorkerFootprintBudget::default(); + if let Some(value) = env_u64("KOLDSTORE_WAL_STARTUP_MS") { + budget.wal_startup = std::time::Duration::from_millis(value); + } + if let Some(value) = env_u64("KOLDSTORE_WAL_IDLE_RSS_MAX_BYTES") { + budget.wal_idle_rss_max_bytes = value; + } + if let Some(value) = env_u64("KOLDSTORE_WAL_IDLE_RSS_SLACK_BYTES") { + budget.wal_idle_rss_slack_bytes = value; + } + if let Some(value) = env_u64("KOLDSTORE_FLUSH_STARTUP_MS") { + budget.flush_startup = std::time::Duration::from_millis(value); + } + if let Some(value) = env_u64("KOLDSTORE_FLUSH_EXECUTOR_RSS_MAX_BYTES") { + budget.flush_executor_rss_max_bytes = value; + } + if let Some(value) = env_u64("KOLDSTORE_FLUSH_CONCURRENT_SELECT_MAX_MS") { + budget.concurrent_select_max = std::time::Duration::from_millis(value); + } + if let Some(value) = env_u64("KOLDSTORE_FLUSH_CONCURRENT_INSERT_MAX_MS") { + budget.concurrent_insert_max = std::time::Duration::from_millis(value); + } + budget +} + /// Returns warmup cycle count (default 3). #[must_use] pub fn warmup_cycles() -> usize { diff --git a/tests/e2e/common/mod.rs b/tests/e2e/common/mod.rs index 6e255fd4..d564cd7f 100644 --- a/tests/e2e/common/mod.rs +++ b/tests/e2e/common/mod.rs @@ -29,15 +29,18 @@ pub use async_mirror::{ force_stop_async_worker, mirror_op_count, release_async_worker_stop_lock, terminate_async_worker, wait_for_async_mirror, wait_for_async_worker, wait_for_async_worker_auto_restart, wait_for_confirmed_flush_at_least, - wait_for_confirmed_flush_past, wait_for_mirror_op_count, wal_lsn_diff_bytes, - AsyncMirrorProgress, + wait_for_confirmed_flush_past, wait_for_mirror_op_count, wait_for_passive_convergence, + wait_for_wal_applier_passively, wal_applier_pid, wal_lsn_diff_bytes, AsyncMirrorProgress, + PassiveKoldStoreState, }; pub use catalog::{ active_job_count, assert_catalog_has_active_schema, assert_change_log_mirror_exists, assert_cold_metadata_present, assert_no_active_jobs, assert_primary_key_columns_match, - assert_system_columns_absent, change_log_mirror_relation, cold_segment_count, manifest_count, - primary_key_columns, published_manifest_count, + assert_system_columns_absent, change_log_mirror_relation, change_log_mirror_relation_name, + change_log_mirror_seq_index_name, change_log_mirror_tombstone_index_name, + change_log_pk_guard_trigger_name, cold_segment_count, manifest_count, primary_key_columns, + published_manifest_count, }; pub use cluster::{ connect, error_chain_contains, expected_pg_ports, expected_pg_versions, local_pg_matrix, diff --git a/tests/e2e/dml/async_change_log_mirror.rs b/tests/e2e/dml/async_change_log_mirror.rs index e5e74f78..740009ee 100644 --- a/tests/e2e/dml/async_change_log_mirror.rs +++ b/tests/e2e/dml/async_change_log_mirror.rs @@ -11,7 +11,7 @@ async fn managed_commit_wakes_sleeping_worker_without_poll_delay() -> Result<()> let db = common::TestDb::start(target, "async_commit_wake").await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let noise = db.relation(&format!("{}_noise", db.schema)); let database: String = db .client @@ -171,7 +171,7 @@ async fn async_mirror_applies_only_committed_wal_in_bounded_batches() -> Result< let db = common::TestDb::start(target, "async_change_log_mirror").await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let publication_exists: bool = db .client @@ -219,7 +219,10 @@ async fn async_mirror_applies_only_committed_wal_in_bounded_batches() -> Result< .iter() .map(|row| row.get::<_, String>(0)) .collect::>(); - assert_eq!(triggers, vec![format!("{table_name}__cl_pk_update_guard"),]); + assert_eq!( + triggers, + vec![common::change_log_pk_guard_trigger_name(&relation)], + ); let worker_start_latency = common::wait_for_async_worker(&db.client).await?; common::log_always(format!( "async mirror worker visible after {worker_start_latency:?}" diff --git a/tests/e2e/dml/async_mirror_worker.rs b/tests/e2e/dml/async_mirror_worker.rs index db13bd72..1de13f13 100644 --- a/tests/e2e/dml/async_mirror_worker.rs +++ b/tests/e2e/dml/async_mirror_worker.rs @@ -12,7 +12,7 @@ async fn async_apply_drains_above_retained_wal_health_threshold() -> Result<()> cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); ensure_publication(&db.client).await?; db.client @@ -67,7 +67,7 @@ async fn async_worker_restarts_after_kill_and_applies_without_duplicates() -> Re cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); ensure_publication(&db.client).await?; db.client @@ -142,7 +142,7 @@ async fn async_worker_recovers_from_apply_failpoint_without_duplicates() -> Resu cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let dbname: String = db .client .query_one("SELECT current_database()::text", &[]) @@ -287,7 +287,7 @@ async fn async_worker_survives_truncate_noise_in_slot() -> Result<()> { cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let noise = format!("{}_noise", db.schema); let noise_relation = db.relation(&noise); @@ -361,7 +361,7 @@ async fn async_apply_mid_tick_abort_rolls_back_applied_lsn() -> Result<()> { cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let dbname: String = db .client .query_one("SELECT current_database()::text", &[]) @@ -468,7 +468,7 @@ async fn async_idle_non_publication_wal_advances_slot_without_reapply() -> Resul cleanup_leftover_async_tables(&db.client).await?; let table_name = format!("{}_events", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); let noise_name = format!("{}_noise", db.schema); let noise = db.relation(&noise_name); diff --git a/tests/e2e/dml/change_feed.rs b/tests/e2e/dml/change_feed.rs index bfb8528a..d8e2d68f 100644 --- a/tests/e2e/dml/change_feed.rs +++ b/tests/e2e/dml/change_feed.rs @@ -56,7 +56,7 @@ async fn change_feed_reads_table_specific_mirror_without_row_events_on_pgrx() -> .await?; db.manage_shared(&table.relation, "id").await?; - let mirror = format!("koldstore.{}__cl", table.table_name); + let mirror = common::change_log_mirror_relation(&table.relation); let rows = db .client .query( diff --git a/tests/e2e/dml/change_log_mirror.rs b/tests/e2e/dml/change_log_mirror.rs index 1978b531..637675f0 100644 --- a/tests/e2e/dml/change_log_mirror.rs +++ b/tests/e2e/dml/change_log_mirror.rs @@ -11,7 +11,7 @@ async fn mirror_tracks_insert_update_delete_reinsert_and_rollback() -> Result<() let db = common::TestDb::start(target.clone(), "change_log_mirror").await?; let table_name = format!("{}_messages", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -122,7 +122,7 @@ async fn mirror_bulk_update_and_delete_keep_latest_state() -> Result<()> { let db = common::TestDb::start(target.clone(), "change_log_mirror_bulk").await?; let table_name = format!("{}_messages", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( diff --git a/tests/e2e/dml/changes_since_latency.rs b/tests/e2e/dml/changes_since_latency.rs index 514850d7..5d7a62b8 100644 --- a/tests/e2e/dml/changes_since_latency.rs +++ b/tests/e2e/dml/changes_since_latency.rs @@ -49,8 +49,11 @@ async fn manage_no_auto_flush_with_hot_limit( .await .with_context(|| format!("manage_table auto_flush=false for {relation}"))?; common::assert_system_columns_absent(&db.client, relation).await?; - let table = relation.rsplit('.').next().unwrap_or(relation); - common::assert_change_log_mirror_exists(&db.client, &format!("koldstore.{table}__cl")).await?; + common::assert_change_log_mirror_exists( + &db.client, + &common::change_log_mirror_relation(relation), + ) + .await?; common::assert_catalog_has_active_schema(&db.client, relation).await?; common::wait_for_async_worker(&db.client).await?; Ok(()) diff --git a/tests/e2e/dml/cold_dml_matrix.rs b/tests/e2e/dml/cold_dml_matrix.rs index d5939270..0cdc4b27 100644 --- a/tests/e2e/dml/cold_dml_matrix.rs +++ b/tests/e2e/dml/cold_dml_matrix.rs @@ -68,7 +68,7 @@ async fn standard_hot_dml_on_managed_table_updates_change_log_mirror_on_pgrx() - .await?; common::fence_async_mirror(&db.client).await?; - let mirror = format!("koldstore.{}__cl", table.table_name); + let mirror = common::change_log_mirror_relation(&table.relation); let row = db .client .query_one( diff --git a/tests/e2e/dml/mod.rs b/tests/e2e/dml/mod.rs index 9de582a9..d654d5db 100644 --- a/tests/e2e/dml/mod.rs +++ b/tests/e2e/dml/mod.rs @@ -8,4 +8,6 @@ mod change_log_mirror; mod changes_since_latency; mod cold_dml_matrix; mod persistent_wal_applier; +mod pgoutput_old_row_cow; +mod wal_applier_footprint; mod wal_only_seq_cursor; diff --git a/tests/e2e/dml/persistent_wal_applier.rs b/tests/e2e/dml/persistent_wal_applier.rs index d9bd894a..fb0488de 100644 --- a/tests/e2e/dml/persistent_wal_applier.rs +++ b/tests/e2e/dml/persistent_wal_applier.rs @@ -530,6 +530,11 @@ async fn persistent_wal_applier_restarts_under_write_flood_without_gaps() -> Res let _cluster = common::acquire_cluster_exclusive()?; common::require_pgrx_server().await?; + const PRE_KILL_MIN: i64 = 25; + const TOTAL_MIN: i64 = 50; + const POST_RESTART_MIN: i64 = 10; + const FLOOD_PROGRESS_DEADLINE: Duration = Duration::from_secs(20); + for target in common::scenario_pg_matrix() { let db = common::TestDb::start(target, "persistent_wal_restart_flood").await?; let table_name = format!("{}_events", db.schema); @@ -575,13 +580,15 @@ async fn persistent_wal_applier_restarts_under_write_flood_without_gaps() -> Res &[&id, &format!("w{writer_idx}-{id}")], ) .await?; - tokio::time::sleep(Duration::from_millis(2)).await; + // Yield only — fixed sleeps make the flood wall-clock bound and + // flake under loaded CI ("restart flood too light"). + tokio::task::yield_now().await; } Ok::<_, anyhow::Error>(()) })); } - tokio::time::sleep(Duration::from_millis(200)).await; + wait_for_insert_progress(&next_id, PRE_KILL_MIN, FLOOD_PROGRESS_DEADLINE).await?; anyhow::ensure!( common::terminate_async_worker(&db.client).await?, "expected to terminate resident WAL applier mid-flood" @@ -590,7 +597,13 @@ async fn persistent_wal_applier_restarts_under_write_flood_without_gaps() -> Res let replacement = wal_applier_pid(&db.client).await?; assert_ne!(replacement, original_pid); - tokio::time::sleep(Duration::from_millis(400)).await; + let at_restart = next_id.load(Ordering::SeqCst) - 1; + wait_for_insert_progress( + &next_id, + (at_restart + POST_RESTART_MIN).max(TOTAL_MIN), + FLOOD_PROGRESS_DEADLINE, + ) + .await?; stop.store(true, Ordering::SeqCst); for (idx, handle) in writers.into_iter().enumerate() { handle @@ -600,7 +613,10 @@ async fn persistent_wal_applier_restarts_under_write_flood_without_gaps() -> Res common::fence_async_mirror(&db.client).await?; let inserted = next_id.load(Ordering::SeqCst) - 1; - anyhow::ensure!(inserted >= 50, "restart flood too light ({inserted} rows)"); + anyhow::ensure!( + inserted >= TOTAL_MIN, + "restart flood too light ({inserted} rows)" + ); let visible: i64 = db .client .query_one(&format!("SELECT count(*)::bigint FROM {relation}"), &[]) @@ -644,3 +660,22 @@ async fn persistent_wal_applier_restarts_under_write_flood_without_gaps() -> Res } Ok(()) } + +async fn wait_for_insert_progress( + next_id: &AtomicI64, + min_inserted: i64, + deadline: Duration, +) -> Result { + let started = Instant::now(); + loop { + let inserted = next_id.load(Ordering::SeqCst) - 1; + if inserted >= min_inserted { + return Ok(inserted); + } + anyhow::ensure!( + started.elapsed() < deadline, + "restart flood too light ({inserted} rows); wanted >= {min_inserted} within {deadline:?}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} diff --git a/tests/e2e/dml/pgoutput_old_row_cow.rs b/tests/e2e/dml/pgoutput_old_row_cow.rs new file mode 100644 index 00000000..0ab23fbf --- /dev/null +++ b/tests/e2e/dml/pgoutput_old_row_cow.rs @@ -0,0 +1,294 @@ +//! Protocol probe: `REPLICA IDENTITY FULL` + pgoutput old tuples as CoW preimages. +//! +//! This is not the production capture path. KoldStore publishes PK columns only +//! and the WAL applier ignores `Update.old`. The test uses a dedicated +//! publication/slot and KoldStore's existing decoder to ask whether PostgreSQL +//! will emit a complete OLD row (including an unchanged toasted payload) when +//! identity is FULL. + +use anyhow::{Context, Result}; +use koldstore_wal_mirror::{decode_message, PgOutputMessage, PgOutputTuple, PgOutputValue}; +use tokio_postgres::Client; + +use crate::common; + +fn pseudo_random_text(len: usize, mut state: u64) -> String { + let mut output = String::with_capacity(len); + for _ in 0..len { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + output.push((b'!' + (state % 94) as u8) as char); + } + output +} + +fn text(value: &PgOutputValue) -> Option<&str> { + match value { + PgOutputValue::Text(value) => Some(value.as_str()), + PgOutputValue::Binary(bytes) => std::str::from_utf8(bytes).ok(), + PgOutputValue::Null | PgOutputValue::UnchangedToast => None, + } +} + +fn assert_text_eq(value: &PgOutputValue, expected: &str, label: &str) -> Result<()> { + let actual = text(value).with_context(|| format!("{label} was not materialized: {value:?}"))?; + anyhow::ensure!(actual == expected, "{label} mismatch"); + Ok(()) +} + +async fn consume_pgoutput( + client: &Client, + slot: &str, + publication: &str, +) -> Result> { + let rows = client + .query( + r#" + SELECT data + FROM pg_catalog.pg_logical_slot_get_binary_changes( + $1, + NULL, + NULL, + 'proto_version', '1', + 'publication_names', $2, + 'messages', 'false' + ) + "#, + &[&slot, &publication], + ) + .await?; + + rows.into_iter() + .map(|row| { + let data: Vec = row.get(0); + decode_message(&data).map_err(anyhow::Error::from) + }) + .collect() +} + +fn update_message(messages: &[PgOutputMessage]) -> Result<(&PgOutputTuple, &PgOutputTuple)> { + let mut found = None; + for message in messages { + if let PgOutputMessage::Update { old, new, .. } = message { + anyhow::ensure!(found.is_none(), "expected one UPDATE, saw more than one"); + let old = old + .as_ref() + .context("REPLICA IDENTITY FULL UPDATE did not contain OLD tuple")?; + found = Some((old, new)); + } + } + found.context("expected one UPDATE pgoutput message") +} + +fn delete_message(messages: &[PgOutputMessage]) -> Result<&PgOutputTuple> { + messages + .iter() + .find_map(|message| match message { + PgOutputMessage::Delete { old, .. } => Some(old), + _ => None, + }) + .context("expected DELETE pgoutput message") +} + +fn row_change_count(messages: &[PgOutputMessage]) -> usize { + messages + .iter() + .filter(|message| { + matches!( + message, + PgOutputMessage::Insert { .. } + | PgOutputMessage::Update { .. } + | PgOutputMessage::Delete { .. } + ) + }) + .count() +} + +#[tokio::test] +async fn replica_identity_full_old_rows_are_viable_copy_on_write_preimages() -> Result<()> { + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "pgoutput_cow_old_row").await?; + let table_name = format!("{}_cow_probe", db.schema); + let relation = db.relation(&table_name); + let publication = format!("{}_cow_pub", db.schema); + let slot = format!("{}_cow_slot", db.schema); + + let result = async { + db.client + .batch_execute(&format!( + r#" + CREATE TABLE {relation} ( + id bigint PRIMARY KEY, + body text NOT NULL, + payload text NOT NULL + ); + ALTER TABLE {relation} REPLICA IDENTITY FULL; + CREATE PUBLICATION {publication} + FOR TABLE {relation} + WITH (publish = 'insert, update, delete'); + "# + )) + .await?; + + db.client + .query_one( + "SELECT slot_name FROM pg_catalog.pg_create_logical_replication_slot($1, 'pgoutput')", + &[&slot], + ) + .await?; + + let replica_identity: String = db + .client + .query_one( + "SELECT relreplident::text FROM pg_catalog.pg_class WHERE oid = $1::text::regclass", + &[&relation], + ) + .await? + .get(0); + anyhow::ensure!(replica_identity == "f", "expected replica identity FULL"); + + // Deterministic, mostly incompressible text large enough to exercise TOAST. + let payload_a = pseudo_random_text(128 * 1024, 0x1234_5678_9abc_def0); + let payload_b = pseudo_random_text(128 * 1024, 0xfedc_ba98_7654_3210); + + db.client + .execute( + &format!("INSERT INTO {relation} (id, body, payload) VALUES (1, $1, $2)"), + &[&"base", &payload_a], + ) + .await?; + let _ = consume_pgoutput(&db.client, &slot, &publication).await?; + + // Critical CoW case: update a small column while a large toasted column is untouched. + db.client + .execute( + &format!("UPDATE {relation} SET body = $1 WHERE id = 1"), + &[&"body-updated"], + ) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + let (old, new) = update_message(&messages)?; + anyhow::ensure!(old.values.len() == 3, "OLD tuple must contain all published columns"); + anyhow::ensure!(new.values.len() == 3, "NEW tuple must contain all published columns"); + assert_text_eq(&old.values[0], "1", "OLD id")?; + assert_text_eq(&old.values[1], "base", "OLD body")?; + assert_text_eq(&old.values[2], &payload_a, "OLD toasted payload")?; + assert_text_eq(&new.values[0], "1", "NEW id")?; + assert_text_eq(&new.values[1], "body-updated", "NEW body")?; + anyhow::ensure!( + matches!(new.values[2], PgOutputValue::UnchangedToast) + || text(&new.values[2]) == Some(payload_a.as_str()), + "NEW unchanged toasted payload must be either materialized or marked UnchangedToast; got {:?}", + new.values[2] + ); + + // Updating the toasted value itself must expose both the previous and new full values. + db.client + .execute( + &format!("UPDATE {relation} SET payload = $1 WHERE id = 1"), + &[&payload_b], + ) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + let (old, new) = update_message(&messages)?; + assert_text_eq(&old.values[1], "body-updated", "second OLD body")?; + assert_text_eq(&old.values[2], &payload_a, "second OLD toasted payload")?; + assert_text_eq(&new.values[2], &payload_b, "second NEW toasted payload")?; + + // Aborted work must never appear in logical decoding. + db.client + .batch_execute(&format!( + "BEGIN; UPDATE {relation} SET body = 'must-not-decode' WHERE id = 1; ROLLBACK;" + )) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + anyhow::ensure!( + row_change_count(&messages) == 0, + "rolled-back DML appeared in pgoutput: {messages:?}" + ); + + // Savepoint rollback is another branch-workflow invariant. + db.client + .batch_execute(&format!( + "BEGIN; SAVEPOINT cow_probe; UPDATE {relation} SET body = 'savepoint-discarded' WHERE id = 1; ROLLBACK TO SAVEPOINT cow_probe; COMMIT;" + )) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + anyhow::ensure!( + row_change_count(&messages) == 0, + "savepoint-rolled-back DML appeared in pgoutput: {messages:?}" + ); + + // Multiple changes in one committed source transaction must remain inside one + // BEGIN/COMMIT boundary, which is required for atomic preimage application. + db.client + .execute( + &format!("INSERT INTO {relation} (id, body, payload) VALUES (2, $1, $2)"), + &[&"peer", &payload_a], + ) + .await?; + let _ = consume_pgoutput(&db.client, &slot, &publication).await?; + db.client + .batch_execute(&format!( + "BEGIN; UPDATE {relation} SET body = 'txn-one' WHERE id = 1; UPDATE {relation} SET body = 'txn-two' WHERE id = 2; COMMIT;" + )) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + let begin_count = messages + .iter() + .filter(|message| matches!(message, PgOutputMessage::Begin { .. })) + .count(); + let commit_count = messages + .iter() + .filter(|message| matches!(message, PgOutputMessage::Commit { .. })) + .count(); + let updates: Vec<_> = messages + .iter() + .filter_map(|message| match message { + PgOutputMessage::Update { old, new, .. } => Some((old.as_ref(), new)), + _ => None, + }) + .collect(); + anyhow::ensure!(begin_count == 1 && commit_count == 1, "expected one source transaction boundary"); + anyhow::ensure!(updates.len() == 2, "expected two UPDATEs in the committed transaction"); + anyhow::ensure!(updates.iter().all(|(old, _)| old.is_some()), "every FULL-identity UPDATE needs OLD"); + for (old, _) in updates { + let old = old.expect("checked above"); + anyhow::ensure!(old.values.len() == 3, "transaction OLD tuple must contain all columns"); + anyhow::ensure!( + text(&old.values[2]).is_some(), + "transaction OLD toasted payload was not materialized: {:?}", + old.values[2] + ); + } + + // DELETE must carry the full OLD row, including the toasted payload. + db.client + .execute(&format!("DELETE FROM {relation} WHERE id = 1"), &[]) + .await?; + let messages = consume_pgoutput(&db.client, &slot, &publication).await?; + let old = delete_message(&messages)?; + anyhow::ensure!(old.values.len() == 3, "DELETE OLD tuple must contain all columns"); + assert_text_eq(&old.values[0], "1", "DELETE OLD id")?; + assert_text_eq(&old.values[1], "txn-one", "DELETE OLD body")?; + assert_text_eq(&old.values[2], &payload_b, "DELETE OLD toasted payload")?; + + Ok::<(), anyhow::Error>(()) + } + .await; + + // Logical slots are database-global resources; clean up even when an assertion fails. + let _ = db + .client + .query("SELECT pg_catalog.pg_drop_replication_slot($1)", &[&slot]) + .await; + let _ = db + .client + .batch_execute(&format!("DROP PUBLICATION IF EXISTS {publication}")) + .await; + + result?; + } + Ok(()) +} diff --git a/tests/e2e/dml/wal_applier_footprint.rs b/tests/e2e/dml/wal_applier_footprint.rs new file mode 100644 index 00000000..07a62c27 --- /dev/null +++ b/tests/e2e/dml/wal_applier_footprint.rs @@ -0,0 +1,178 @@ +//! WAL applier process footprint: startup time and idle RSS. +//! +//! The launcher must stay a quiet PostgreSQL backend. Restart is supervisor- +//! driven (`BGW_NEVER_RESTART` + re-register), so the SLO includes the 1 s +//! child-lifecycle grace. + +use std::time::Duration; + +use anyhow::{bail, Result}; +use koldstore_memory::format_bytes; + +use crate::common; + +async fn backend_pid(client: &tokio_postgres::Client) -> Result { + Ok(client + .query_one("SELECT pg_backend_pid()::int4", &[]) + .await? + .get(0)) +} + +async fn idle_without_xact(client: &tokio_postgres::Client, pid: i32) -> Result { + Ok(client + .query_one( + "SELECT xact_start IS NULL FROM pg_catalog.pg_stat_activity WHERE pid = $1", + &[&pid], + ) + .await? + .get(0)) +} + +async fn manage_events(db: &common::TestDb, relation: &str) -> Result<()> { + db.client + .batch_execute(&format!( + "CREATE TABLE {relation} (id bigint PRIMARY KEY, body text NOT NULL)" + )) + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 1000, + auto_flush => false + ) + "#, + &[&relation, &db.storage_name], + ) + .await?; + common::wait_for_async_worker(&db.client).await?; + common::fence_async_mirror(&db.client).await?; + Ok(()) +} + +/// Idle WAL applier RSS must stay near a sibling client backend and under cap. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn idle_wal_applier_rss_stays_near_a_client_backend() -> Result<()> { + let _cluster = common::acquire_cluster_exclusive()?; + common::require_pgrx_server().await?; + let budget = common::memory::worker_footprint_budget_from_env(); + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "wal_footprint_rss").await?; + let relation = db.relation(&format!("{}_events", db.schema)); + manage_events(&db, &relation).await?; + + let wal_pid = common::wal_applier_pid(&db.client).await?; + tokio::time::sleep(Duration::from_millis(250)).await; + anyhow::ensure!( + idle_without_xact(&db.client, wal_pid).await?, + "idle WAL applier pid={wal_pid} must not hold an open transaction" + ); + + let client_pid = backend_pid(&db.client).await?; + let wal_rss = common::memory::pid_rss_bytes(wal_pid)?; + let client_rss = common::memory::pid_rss_bytes(client_pid)?; + let allowed = client_rss.saturating_add(budget.wal_idle_rss_slack_bytes); + common::log_always(format!( + "idle WAL applier pid={wal_pid} rss={} client_pid={client_pid} rss={} cap={} slack_cap={}", + format_bytes(wal_rss), + format_bytes(client_rss), + format_bytes(budget.wal_idle_rss_max_bytes), + format_bytes(allowed), + )); + if wal_rss > budget.wal_idle_rss_max_bytes { + bail!( + "idle WAL applier rss {} exceeded cap {}", + format_bytes(wal_rss), + format_bytes(budget.wal_idle_rss_max_bytes) + ); + } + if wal_rss > allowed { + bail!( + "idle WAL applier rss {} exceeded sibling backend {} by more than {}", + format_bytes(wal_rss), + format_bytes(client_rss), + format_bytes(budget.wal_idle_rss_slack_bytes) + ); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + let wal_rss_later = common::memory::pid_rss_bytes(wal_pid)?; + let idle_growth = wal_rss_later.saturating_sub(wal_rss); + if idle_growth > 16 * 1024 * 1024 { + bail!( + "idle WAL applier rss grew {} while sleeping ({} → {})", + format_bytes(idle_growth), + format_bytes(wal_rss), + format_bytes(wal_rss_later) + ); + } + + db.client + .query_one( + "SELECT koldstore.unmanage_table($1::text::regclass, true, true)", + &[&relation], + ) + .await?; + let _ = db + .client + .query_one("SELECT koldstore.disable_async_mirror()", &[]) + .await?; + } + Ok(()) +} + +/// Killing the resident applier must respawn within the startup SLO. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wal_applier_restart_stays_within_startup_budget() -> Result<()> { + let _cluster = common::acquire_cluster_exclusive()?; + common::require_pgrx_server().await?; + let budget = common::memory::worker_footprint_budget_from_env(); + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "wal_footprint_start").await?; + let relation = db.relation(&format!("{}_events", db.schema)); + manage_events(&db, &relation).await?; + + let original_pid = common::wal_applier_pid(&db.client).await?; + anyhow::ensure!( + common::terminate_async_worker(&db.client).await?, + "expected to terminate the resident WAL applier" + ); + let elapsed = common::wait_for_async_worker_auto_restart(&db.client, original_pid).await?; + common::log_always(format!( + "WAL applier restart {original_pid} -> new pid in {elapsed:?} (budget {:?})", + budget.wal_startup + )); + if elapsed > budget.wal_startup { + bail!( + "WAL applier restart took {elapsed:?}, budget {:?}", + budget.wal_startup + ); + } + + let replacement = common::wal_applier_pid(&db.client).await?; + let replacement_rss = common::memory::pid_rss_bytes(replacement)?; + if replacement_rss > budget.wal_idle_rss_max_bytes { + bail!( + "restarted WAL applier rss {} exceeded cap {}", + format_bytes(replacement_rss), + format_bytes(budget.wal_idle_rss_max_bytes) + ); + } + + db.client + .query_one( + "SELECT koldstore.unmanage_table($1::text::regclass, true, true)", + &[&relation], + ) + .await?; + let _ = db + .client + .query_one("SELECT koldstore.disable_async_mirror()", &[]) + .await?; + } + Ok(()) +} diff --git a/tests/e2e/dml/wal_only_seq_cursor.rs b/tests/e2e/dml/wal_only_seq_cursor.rs index 5823884f..921a2d27 100644 --- a/tests/e2e/dml/wal_only_seq_cursor.rs +++ b/tests/e2e/dml/wal_only_seq_cursor.rs @@ -20,7 +20,7 @@ async fn wal_only_empty_activation_has_no_capture_triggers() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_empty", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -32,7 +32,7 @@ async fn wal_only_empty_activation_has_no_capture_triggers() -> Result<()> { let triggers = source_triggers(&db.client, &relation).await?; assert_eq!( triggers, - vec![format!("{table_name}__cl_pk_update_guard")], + vec![common::change_log_pk_guard_trigger_name(&relation)], "WAL-only activation must not install DML capture triggers" ); @@ -78,7 +78,7 @@ async fn wal_apply_assigns_seq_in_commit_order_not_start_order() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_order", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -150,7 +150,7 @@ async fn changes_since_seq_pagination_survives_flush() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_feed", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -256,7 +256,7 @@ async fn worker_restart_keeps_seq_above_durable_watermark() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_wm", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -323,7 +323,7 @@ async fn populated_activation_under_concurrent_dml_is_gap_free() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_pop", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -368,7 +368,7 @@ async fn populated_activation_under_concurrent_dml_is_gap_free() -> Result<()> { let triggers = source_triggers(&db.client, &relation).await?; assert_eq!( triggers, - vec![format!("{table_name}__cl_pk_update_guard")], + vec![common::change_log_pk_guard_trigger_name(&relation)], "populated activation must remain WAL-only" ); @@ -414,8 +414,10 @@ async fn changes_since_hot_mirror_index_scan_for_seq_pagination() -> Result<()> ensure_publication(&db.client).await?; let table_name = format!("{}_idx", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); - let seq_index = format!("{table_name}__cl_seq_idx"); + let mirror = common::change_log_mirror_relation(&relation); + let mirror_name = common::change_log_mirror_relation_name(&relation); + let seq_index = common::change_log_mirror_seq_index_name(&relation); + let tombstone_index = common::change_log_mirror_tombstone_index_name(&relation); db.client .batch_execute(&format!( @@ -442,7 +444,7 @@ async fn changes_since_hot_mirror_index_scan_for_seq_pagination() -> Result<()> "SELECT indexname::text FROM pg_indexes \ WHERE schemaname = 'koldstore' AND tablename = $1 \ ORDER BY indexname", - &[&format!("{table_name}__cl")], + &[&mirror_name], ) .await? .into_iter() @@ -453,9 +455,7 @@ async fn changes_since_hot_mirror_index_scan_for_seq_pagination() -> Result<()> "expected {seq_index} among {indexes:?}" ); assert!( - indexes - .iter() - .any(|name| name == &format!("{table_name}__cl_tombstone_seq_idx")), + indexes.iter().any(|name| name == &tombstone_index), "expected tombstone seq index among {indexes:?}" ); @@ -511,7 +511,7 @@ async fn changes_since_pagination_no_skip_or_dup_under_concurrent_dml() -> Resul ensure_publication(&db.client).await?; let table_name = format!("{}_race", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -650,7 +650,7 @@ async fn changes_since_includes_delete_revive_and_omits_rollback() -> Result<()> ensure_publication(&db.client).await?; let table_name = format!("{}_life", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -746,7 +746,7 @@ async fn changes_since_varied_limits_cover_all_live_latest_state() -> Result<()> ensure_publication(&db.client).await?; let table_name = format!("{}_lim", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -803,7 +803,7 @@ async fn changes_since_flush_prune_exposes_retention_floor_not_silent_catchup() ensure_publication(&db.client).await?; let table_name = format!("{}_gap", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -940,7 +940,7 @@ async fn changes_since_merges_cold_oldest_and_hot_newest_with_mid_cursor() -> Re ensure_publication(&db.client).await?; let table_name = format!("{}_mix", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -1115,7 +1115,7 @@ async fn changes_since_limit_does_not_open_newer_unneeded_segments() -> Result<( ensure_publication(&db.client).await?; let table_name = format!("{}_bounded", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -1273,7 +1273,7 @@ async fn changes_since_from_start_keeps_cold_before_hot_across_segments() -> Res ensure_publication(&db.client).await?; let table_name = format!("{}_order", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -1449,7 +1449,7 @@ async fn changes_since_last_rows_rewinds_newest_n_like_kalamdb() -> Result<()> { ensure_publication(&db.client).await?; let table_name = format!("{}_last", db.schema); let relation = db.relation(&table_name); - let mirror = format!("koldstore.{table_name}__cl"); + let mirror = common::change_log_mirror_relation(&relation); db.client .batch_execute(&format!( @@ -1531,6 +1531,124 @@ async fn changes_since_last_rows_rewinds_newest_n_like_kalamdb() -> Result<()> { Ok(()) } +/// `last_rows` must walk multiple cold segments when the tip file is smaller +/// than the requested window (multi-wave flush). +#[tokio::test] +async fn changes_since_last_rows_spans_multiple_cold_segments() -> Result<()> { + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "wal_changes_last_multi_seg").await?; + ensure_publication(&db.client).await?; + let table_name = format!("{}_last_ms", db.schema); + let relation = db.relation(&table_name); + let mirror = common::change_log_mirror_relation(&relation); + + db.client + .batch_execute(&format!( + "CREATE TABLE {relation} (id bigint PRIMARY KEY, body text NOT NULL); + SET koldstore.min_max_rows_per_file = 1;" + )) + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => NULL, + min_flush_rows => 1, + max_rows_per_file => 100, + auto_flush => false, + migration_order_by => 'id' + ) + "#, + &[&relation, &db.storage_name], + ) + .await?; + common::wait_for_async_worker(&db.client).await?; + + // Three flush waves → at least three cold segments with 60 rows each. + for wave in 0..3_i64 { + let lo = wave * 60 + 1; + let hi = (wave + 1) * 60; + db.client + .execute( + &format!( + "INSERT INTO {relation} + SELECT id, 'row-' || id + FROM generate_series({lo}::bigint, {hi}::bigint) id" + ), + &[], + ) + .await?; + common::wait_for_mirror_op_count(&db.client, &mirror, 1, 60).await?; + common::wait_for_async_mirror(&db.client).await?; + common::flush_table_job_id(&db.client, &relation, true).await?; + } + + let segments = common::cold_segment_count(&db.client, &relation).await?; + anyhow::ensure!( + segments >= 3, + "expected multi-segment flush, got {segments} segments" + ); + + let last = db + .client + .query( + "SELECT (pk->>'id')::bigint AS id, source \ + FROM koldstore.changes_since($1::text::regclass, 0, 1000, 100) \ + ORDER BY seq", + &[&relation], + ) + .await?; + assert_eq!( + last.len(), + 100, + "last_rows=100 must fill across multiple cold segments (got {})", + last.len() + ); + let ids: Vec = last.iter().map(|row| row.get(0)).collect(); + assert_eq!(ids, (81..=180).collect::>()); + assert!( + last.iter().all(|row| row.get::<_, String>(1) == "cold"), + "expected cold sources after full prune" + ); + + // Leave a small hot tip and ensure last_rows still fills from cold+hot. + db.client + .execute( + &format!( + "INSERT INTO {relation} SELECT id, 'live-' || id FROM generate_series(181, 190) id" + ), + &[], + ) + .await?; + common::fence_async_mirror(&db.client).await?; + let mixed = db + .client + .query( + "SELECT (pk->>'id')::bigint AS id, source \ + FROM koldstore.changes_since($1::text::regclass, 0, 1000, 40) \ + ORDER BY seq", + &[&relation], + ) + .await?; + assert_eq!(mixed.len(), 40); + let mixed_ids: Vec = mixed.iter().map(|row| row.get(0)).collect(); + assert_eq!(mixed_ids, (151..=190).collect::>()); + assert!( + mixed.iter().any(|row| row.get::<_, String>(1) == "hot"), + "mixed last_rows window must include hot tip" + ); + assert!( + mixed.iter().any(|row| row.get::<_, String>(1) == "cold"), + "mixed last_rows window must still pull cold history" + ); + + unmanage(&db.client, &relation).await?; + } + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ChangeRow { id: i64, diff --git a/tests/e2e/flush/flush_async_prune_race.rs b/tests/e2e/flush/flush_async_prune_race.rs index ce4b64be..641e40cb 100644 --- a/tests/e2e/flush/flush_async_prune_race.rs +++ b/tests/e2e/flush/flush_async_prune_race.rs @@ -36,8 +36,11 @@ async fn seed_async_table(db: &common::TestDb, table_name: &str, rows: i64) -> R ) .await?; common::assert_system_columns_absent(&db.client, &relation).await?; - common::assert_change_log_mirror_exists(&db.client, &format!("koldstore.{table_name}__cl")) - .await?; + common::assert_change_log_mirror_exists( + &db.client, + &common::change_log_mirror_relation(&relation), + ) + .await?; common::assert_catalog_has_active_schema(&db.client, &relation).await?; db.client .batch_execute(&format!( diff --git a/tests/e2e/flush/flush_autonomous.rs b/tests/e2e/flush/flush_autonomous.rs new file mode 100644 index 00000000..c6814805 --- /dev/null +++ b/tests/e2e/flush/flush_autonomous.rs @@ -0,0 +1,420 @@ +//! Autonomous WAL-to-flush liveness and generation-race coverage. + +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::{common, flush::harness}; + +const CONVERGENCE_DEADLINE: Duration = Duration::from_secs(60); + +async fn database_name(client: &tokio_postgres::Client) -> Result { + Ok(client + .query_one("SELECT current_database()::text", &[]) + .await? + .get(0)) +} + +async fn completed_jobs(client: &tokio_postgres::Client, relation: &str) -> Result { + Ok(client + .query_one( + r#" + SELECT count(*)::bigint + FROM koldstore.jobs j + WHERE j.table_oid = $1::text::regclass::oid + AND j.job_type = 'flush' + AND j.status = 'completed' + AND j.created_at >= ( + SELECT created_at FROM koldstore.schemas + WHERE table_oid = $1::text::regclass::oid AND active + ) + "#, + &[&relation], + ) + .await? + .get(0)) +} + +async fn manage_auto( + db: &common::TestDb, + relation: &str, + hot_row_limit: i64, + max_rows_per_file: i64, +) -> Result<()> { + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => $3, + min_flush_rows => 1, + max_rows_per_file => $4, + migration_order_by => 'id', + auto_flush => true + ) + "#, + &[ + &relation, + &db.storage_name, + &hot_row_limit, + &max_rows_per_file, + ], + ) + .await?; + Ok(()) +} + +/// DML committed while an automatic flush is active must be absorbed by that +/// job or a follow-up job without a fence, ensure, tick, retry, or restart. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn auto_flush_converges_writes_arriving_during_active_flush() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "auto_flush_followup").await?; + let table_name = format!("{}_events", db.schema); + let relation = db.relation(&table_name); + let dbname = database_name(&db.client).await?; + + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" SET koldstore.flush_execution = 'queue'; + ALTER DATABASE "{dbname}" SET koldstore.flush_check_interval_seconds = 1; + ALTER DATABASE "{dbname}" SET koldstore.max_parallel_flush_jobs = 1; + ALTER DATABASE "{dbname}" SET koldstore.min_max_rows_per_file = 1; + ALTER DATABASE "{dbname}" SET koldstore.failpoint = 'wait:after_cleanup_before_job_complete'; + SET koldstore.flush_execution = 'queue'; + SET koldstore.min_max_rows_per_file = 1; + CREATE TABLE {relation} (id bigint PRIMARY KEY, body text NOT NULL); + "# + )) + .await?; + manage_auto(&db, &relation, 8, 16).await?; + let original_wal_pid = + common::wait_for_wal_applier_passively(&db.client, Duration::from_secs(30)).await?; + + let coordinator = harness::connect_peer(&db).await?; + harness::barrier_lock(&coordinator).await?; + db.client + .batch_execute(&format!( + "INSERT INTO {relation} SELECT id, 'wave-1-' || id FROM generate_series(1, 64) id" + )) + .await?; + harness::wait_until_barrier_waiter(&coordinator, || false).await?; + + db.client + .batch_execute(&format!( + r#" + INSERT INTO {relation} + SELECT id, 'wave-2-' || id FROM generate_series(65, 128) id; + UPDATE {relation} SET body = 'updated-after-first-selection' WHERE id = 1; + DELETE FROM {relation} WHERE id = 2; + INSERT INTO {relation} VALUES (2, 'reinserted-after-delete'); + "# + )) + .await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; + db.client + .batch_execute(&format!( + "ALTER DATABASE \"{dbname}\" RESET koldstore.failpoint" + )) + .await?; + harness::barrier_unlock(&coordinator).await?; + + let state = common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 8, + CONVERGENCE_DEADLINE, + ) + .await?; + anyhow::ensure!(state.wal_pid == Some(original_wal_pid)); + anyhow::ensure!(common::relation_row_count(&db.client, &relation).await? == 128); + let bodies = db + .client + .query( + &format!("SELECT id, body FROM {relation} WHERE id IN (1, 2) ORDER BY id"), + &[], + ) + .await?; + anyhow::ensure!(bodies[0].get::<_, String>(1) == "updated-after-first-selection"); + anyhow::ensure!(bodies[1].get::<_, String>(1) == "reinserted-after-delete"); + common::assert_pk_unique(&db.client, &relation, &["id"]).await?; + Ok(()) +} + +/// A queue generation published while the prior executor is blocked must not +/// be acknowledged by that older executor. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn queue_generation_published_during_blocked_executor_is_not_lost() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "flush_generation_race").await?; + let table_a_name = format!("{}_table_a", db.schema); + let table_b_name = format!("{}_table_b", db.schema); + let table_a = db.create_indexed_items_table(&table_a_name, 64).await?; + let table_b = db.create_indexed_items_table(&table_b_name, 64).await?; + let dbname = database_name(&db.client).await?; + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" SET koldstore.flush_execution = 'queue'; + ALTER DATABASE "{dbname}" SET koldstore.max_parallel_flush_jobs = 1; + ALTER DATABASE "{dbname}" SET koldstore.min_max_rows_per_file = 1; + ALTER DATABASE "{dbname}" SET koldstore.failpoint = 'wait:after_select_rows'; + SET koldstore.flush_execution = 'queue'; + SET koldstore.min_max_rows_per_file = 1; + "# + )) + .await?; + for relation in [&table_a.relation, &table_b.relation] { + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 4, + min_flush_rows => 1, + max_rows_per_file => 8, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[relation, &db.storage_name], + ) + .await?; + } + common::fence_async_mirror(&db.client).await?; + + let coordinator = harness::connect_peer(&db).await?; + harness::barrier_lock(&coordinator).await?; + let job_a: String = db + .client + .query_one( + "SELECT koldstore.enqueue_flush_job(table_name => $1::text::regclass, force => true)::text", + &[&table_a.relation], + ) + .await? + .get(0); + harness::wait_until_barrier_waiter(&coordinator, || false).await?; + let job_b: String = db + .client + .query_one( + "SELECT koldstore.enqueue_flush_job(table_name => $1::text::regclass, force => true)::text", + &[&table_b.relation], + ) + .await? + .get(0); + db.client + .batch_execute(&format!( + "ALTER DATABASE \"{dbname}\" RESET koldstore.failpoint" + )) + .await?; + harness::barrier_unlock(&coordinator).await?; + common::wait_for_flush_job_terminal(&db.client, &job_a).await?; + common::wait_for_flush_job_terminal(&db.client, &job_b).await?; + common::assert_no_active_jobs(&db.client, &table_a.relation).await?; + common::assert_no_active_jobs(&db.client, &table_b.relation).await?; + Ok(()) +} + +/// Flush cleanup WAL must not be decoded as fresh user DML and create an +/// endless chain of automatic jobs. +#[tokio::test] +async fn automatic_flush_does_not_trigger_itself_while_idle() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "auto_flush_idle_loop").await?; + let table_name = format!("{}_events", db.schema); + let relation = db.relation(&table_name); + let dbname = database_name(&db.client).await?; + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" SET koldstore.flush_execution = 'queue'; + ALTER DATABASE "{dbname}" SET koldstore.flush_check_interval_seconds = 1; + ALTER DATABASE "{dbname}" SET koldstore.min_max_rows_per_file = 1; + SET koldstore.flush_execution = 'queue'; + SET koldstore.min_max_rows_per_file = 1; + CREATE TABLE {relation} (id bigint PRIMARY KEY, body text NOT NULL); + "# + )) + .await?; + manage_auto(&db, &relation, 5, 5).await?; + let wal_pid = + common::wait_for_wal_applier_passively(&db.client, Duration::from_secs(30)).await?; + db.client + .batch_execute(&format!( + "INSERT INTO {relation} SELECT id, 'body-' || id FROM generate_series(1, 20) id" + )) + .await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; + let settled = common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 5, + CONVERGENCE_DEADLINE, + ) + .await?; + let completed = completed_jobs(&db.client, &relation).await?; + tokio::time::sleep(Duration::from_secs(4)).await; + let still_settled = common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + completed, + 5, + Duration::from_secs(5), + ) + .await?; + anyhow::ensure!(completed_jobs(&db.client, &relation).await? == completed); + anyhow::ensure!(settled.wal_pid == Some(wal_pid)); + anyhow::ensure!(still_settled.wal_pid == Some(wal_pid)); + anyhow::ensure!(still_settled.wal_generation == still_settled.wal_processed_generation); + Ok(()) +} + +/// A source transaction larger than one decoder fetch must become visible in +/// one mirror transaction, honor savepoint rollback, and then auto-flush. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn large_transaction_with_savepoint_applies_atomically_then_auto_flushes() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "large_txn_auto_flush").await?; + let table_name = format!("{}_events", db.schema); + let relation = db.relation(&table_name); + let mirror = common::change_log_mirror_relation(&relation); + let dbname = database_name(&db.client).await?; + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" SET koldstore.flush_execution = 'queue'; + ALTER DATABASE "{dbname}" SET koldstore.flush_check_interval_seconds = 1; + ALTER DATABASE "{dbname}" SET koldstore.min_max_rows_per_file = 1; + SET koldstore.flush_execution = 'queue'; + SET koldstore.min_max_rows_per_file = 1; + CREATE TABLE {relation} (id bigint PRIMARY KEY, body text NOT NULL); + "# + )) + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 100, + min_flush_rows => 1, + max_rows_per_file => 1000, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[&relation, &db.storage_name], + ) + .await?; + common::wait_for_wal_applier_passively(&db.client, Duration::from_secs(30)).await?; + + db.client + .batch_execute(&format!( + r#" + BEGIN; + INSERT INTO {relation} + SELECT id, 'insert-' || id FROM generate_series(1, 12000) id; + SAVEPOINT rejected_work; + UPDATE {relation} SET body = 'must-be-rolled-back' WHERE id BETWEEN 1 AND 2000; + ROLLBACK TO SAVEPOINT rejected_work; + UPDATE {relation} SET body = 'committed-update' WHERE id BETWEEN 2001 AND 4000; + DELETE FROM {relation} WHERE id % 10 = 0; + COMMIT; + "# + )) + .await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; + + let observer = common::connect(&db.target).await?; + let started = std::time::Instant::now(); + loop { + let count: i64 = observer + .query_one(&format!("SELECT count(*)::bigint FROM {mirror}"), &[]) + .await? + .get(0); + anyhow::ensure!( + count == 0 || count == 12_000, + "another backend observed a partial source transaction: {count}" + ); + if count == 12_000 { + break; + } + anyhow::ensure!( + started.elapsed() <= Duration::from_secs(30), + "large source transaction was not applied within 30s" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let operations = db + .client + .query_one( + &format!( + r#" + SELECT + count(*) FILTER (WHERE op = 1)::bigint, + count(*) FILTER (WHERE op = 2)::bigint, + count(*) FILTER (WHERE op = 3)::bigint + FROM {mirror} + "# + ), + &[], + ) + .await?; + anyhow::ensure!(operations.get::<_, i64>(0) == 9_000); + anyhow::ensure!(operations.get::<_, i64>(1) == 1_800); + anyhow::ensure!(operations.get::<_, i64>(2) == 1_200); + + db.client + .execute( + "SELECT koldstore.set_table_auto_flush($1::text::regclass, true)", + &[&relation], + ) + .await?; + common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 100, + Duration::from_secs(90), + ) + .await?; + anyhow::ensure!(common::relation_row_count(&db.client, &relation).await? == 10_800); + let rolled_back: i64 = db + .client + .query_one( + &format!("SELECT count(*)::bigint FROM {relation} WHERE body = 'must-be-rolled-back'"), + &[], + ) + .await? + .get(0); + anyhow::ensure!(rolled_back == 0); + common::assert_pk_unique(&db.client, &relation, &["id"]).await?; + Ok(()) +} diff --git a/tests/e2e/flush/flush_contract.rs b/tests/e2e/flush/flush_contract.rs new file mode 100644 index 00000000..2a1899da --- /dev/null +++ b/tests/e2e/flush/flush_contract.rs @@ -0,0 +1,78 @@ +//! Fail-closed SQL contracts required by WAL-only capture. + +use anyhow::{Context, Result}; + +use crate::common; + +/// TRUNCATE has no row-level logical changes for the mirror decoder, so every +/// form touching a managed table must fail atomically before utility execution. +#[tokio::test] +async fn truncate_is_rejected_before_and_after_cold_publication() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "truncate_rejected").await?; + let table_name = format!("{}_items", db.schema); + let table = db.create_indexed_items_table(&table_name, 100).await?; + db.client + .batch_execute("SET koldstore.min_max_rows_per_file = 1") + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 10, + min_flush_rows => 1, + max_rows_per_file => 10, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[&table.relation, &db.storage_name], + ) + .await?; + common::fence_async_mirror(&db.client).await?; + + for statement in [ + format!("TRUNCATE TABLE {}", table.relation), + format!("TRUNCATE ONLY {}", table.relation), + format!("TRUNCATE TABLE {} RESTART IDENTITY", table.relation), + ] { + anyhow::ensure!( + db.client.batch_execute(&statement).await.is_err(), + "managed statement must fail closed: {statement}" + ); + anyhow::ensure!(common::relation_row_count(&db.client, &table.relation).await? == 100); + } + + let unmanaged = db.relation("truncate_unmanaged"); + db.client + .batch_execute(&format!( + "CREATE TABLE {unmanaged} (id bigint); INSERT INTO {unmanaged} VALUES (1)" + )) + .await?; + let mixed = format!("TRUNCATE TABLE {unmanaged}, {}", table.relation); + anyhow::ensure!( + db.client.batch_execute(&mixed).await.is_err(), + "mixed managed/unmanaged TRUNCATE must reject the whole statement" + ); + anyhow::ensure!(common::relation_row_count(&db.client, &unmanaged).await? == 1); + + let flushed = db.flush_table_with_force(&table.relation, true).await?; + anyhow::ensure!(flushed > 0); + let before = common::relation_row_count(&db.client, &table.relation).await?; + anyhow::ensure!( + db.client + .batch_execute(&format!("TRUNCATE TABLE {} CASCADE", table.relation)) + .await + .is_err(), + "managed TRUNCATE after cold publication must fail closed" + ); + anyhow::ensure!(common::relation_row_count(&db.client, &table.relation).await? == before); + common::assert_pk_unique(&db.client, &table.relation, &["id"]).await?; + Ok(()) +} diff --git a/tests/e2e/flush/flush_executor_footprint.rs b/tests/e2e/flush/flush_executor_footprint.rs new file mode 100644 index 00000000..c762e7cf --- /dev/null +++ b/tests/e2e/flush/flush_executor_footprint.rs @@ -0,0 +1,306 @@ +//! Flush executor process footprint: startup time, RSS, and concurrent latency. +//! +//! Queue-mode executors are one-shot backends. They must appear quickly, stay +//! O(file) in RSS at product defaults, and not stall hot PK work on other +//! sessions while Parquet encode runs. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use koldstore_memory::format_bytes; + +use crate::common; + +const SEED_ROWS: i64 = 8_000; +const HOT_ROW_LIMIT: i64 = 1_000; + +async fn enable_queue_flush(db: &common::TestDb) -> Result { + let dbname: String = db + .client + .query_one("SELECT current_database()::text", &[]) + .await? + .get(0); + db.client + .batch_execute(&format!( + "ALTER DATABASE \"{dbname}\" SET koldstore.flush_execution = 'queue'; \ + ALTER DATABASE \"{dbname}\" SET koldstore.max_parallel_flush_jobs = 1; \ + SET koldstore.flush_execution = 'queue'; \ + SET koldstore.max_parallel_flush_jobs = 1;" + )) + .await + .context("enable queue flush_execution")?; + Ok(dbname) +} + +async fn reset_flush_execution(db: &common::TestDb, dbname: &str) -> Result<()> { + db.client + .batch_execute(&format!( + "ALTER DATABASE \"{dbname}\" RESET koldstore.flush_execution; \ + ALTER DATABASE \"{dbname}\" RESET koldstore.max_parallel_flush_jobs; \ + RESET koldstore.flush_execution; \ + RESET koldstore.max_parallel_flush_jobs;" + )) + .await + .ok(); + Ok(()) +} + +async fn manage_for_queue_flush(db: &common::TestDb, relation: &str) -> Result<()> { + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => $3::bigint, + min_flush_rows => 1, + max_rows_per_file => 1000, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[&relation, &db.storage_name, &HOT_ROW_LIMIT], + ) + .await?; + common::wait_for_async_worker(&db.client).await?; + common::fence_async_mirror(&db.client).await?; + Ok(()) +} + +fn median_duration(samples: &mut [Duration]) -> Duration { + samples.sort(); + samples[samples.len() / 2] +} + +/// Queue flush executor must start within budget and stay under the RSS cap. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn queue_flush_executor_startup_and_rss_stay_within_budget() -> Result<()> { + common::require_pgrx_server().await?; + let budget = common::memory::worker_footprint_budget_from_env(); + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "flush_footprint_rss").await?; + let dbname = enable_queue_flush(&db).await?; + let table = db + .create_indexed_items_table("flush_footprint_items", SEED_ROWS) + .await?; + manage_for_queue_flush(&db, &table.relation).await?; + + let stop = Arc::new(AtomicBool::new(false)); + let peak_rss = Arc::new(AtomicU64::new(0)); + let seen_pid = Arc::new(AtomicU64::new(0)); + let poll_client = common::connect_peer(&db).await?; + let poll_stop = Arc::clone(&stop); + let poll_peak = Arc::clone(&peak_rss); + let poll_seen = Arc::clone(&seen_pid); + let poller = tokio::spawn(async move { + while !poll_stop.load(Ordering::Relaxed) { + if let Ok(pids) = common::flush_executor_pids(&poll_client).await { + for pid in pids { + poll_seen.store(u64::try_from(pid).unwrap_or(0), Ordering::Relaxed); + if let Ok(rss) = common::memory::pid_rss_bytes(pid) { + poll_peak.fetch_max(rss, Ordering::Relaxed); + } + } + } + tokio::time::sleep(Duration::from_millis(15)).await; + } + }); + + let started = Instant::now(); + let job_id = common::flush_table_job_id(&db.client, &table.relation, true) + .await? + .context("force flush must return a job id")?; + let mut appeared = started.elapsed(); + let wait_deadline = budget + .flush_startup + .saturating_add(Duration::from_millis(250)); + let wait_until = Instant::now() + wait_deadline; + while seen_pid.load(Ordering::Relaxed) == 0 && Instant::now() < wait_until { + tokio::time::sleep(Duration::from_millis(10)).await; + } + if seen_pid.load(Ordering::Relaxed) != 0 { + appeared = started.elapsed(); + } + + let flushed = common::wait_for_flush_job_terminal(&db.client, &job_id).await?; + stop.store(true, Ordering::Relaxed); + let _ = poller.await; + common::wait_until_no_flush_executors(&db.client, Duration::from_secs(10)).await?; + + anyhow::ensure!(flushed > 0, "queue flush archived no rows"); + let peak = peak_rss.load(Ordering::Relaxed); + common::log_always(format!( + "flush executor startup={appeared:?} peak_rss={} job={job_id} rows={flushed}", + format_bytes(peak) + )); + if appeared > budget.flush_startup { + bail!( + "flush executor startup {appeared:?} exceeded budget {:?}", + budget.flush_startup + ); + } + if peak == 0 { + bail!( + "flush executor PID was never sampled (job finished in {appeared:?}); \ + cannot bound process RSS" + ); + } + if peak > budget.flush_executor_rss_max_bytes { + bail!( + "flush executor peak rss {} exceeded cap {}", + format_bytes(peak), + format_bytes(budget.flush_executor_rss_max_bytes) + ); + } + + reset_flush_execution(&db, &dbname).await?; + } + Ok(()) +} + +/// A concurrent session's hot PK reads and inserts must stay within latency SLO +/// while a queue flush executor encodes Parquet. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn queue_flush_does_not_stall_concurrent_hot_pk_work() -> Result<()> { + common::require_pgrx_server().await?; + let budget = common::memory::worker_footprint_budget_from_env(); + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "flush_footprint_lat").await?; + let dbname = enable_queue_flush(&db).await?; + let table = db + .create_indexed_items_table("flush_lat_items", SEED_ROWS) + .await?; + manage_for_queue_flush(&db, &table.relation).await?; + + let wal_pid = common::wal_applier_pid(&db.client).await?; + let hot_id = SEED_ROWS; + let probe = common::connect_peer(&db).await?; + let mut baseline = Vec::with_capacity(20); + for _ in 0..20 { + let started = Instant::now(); + let found: i64 = probe + .query_one( + &format!("SELECT id FROM {} WHERE id = $1", table.relation), + &[&hot_id], + ) + .await? + .get(0); + anyhow::ensure!(found == hot_id); + baseline.push(started.elapsed()); + } + let baseline_median = median_duration(&mut baseline); + + let job_id = common::flush_table_job_id(&db.client, &table.relation, true) + .await? + .context("force flush must return a job id")?; + + let stop = Arc::new(AtomicBool::new(false)); + let relation = table.relation.clone(); + let probe_stop = Arc::clone(&stop); + let latency = tokio::spawn(async move { + let mut selects = Vec::new(); + let mut inserts = Vec::new(); + let mut ping = Vec::new(); + let mut next_id = SEED_ROWS + 1; + while !probe_stop.load(Ordering::Relaxed) { + let started = Instant::now(); + let _: i32 = probe.query_one("SELECT 1::int4", &[]).await?.get(0); + ping.push(started.elapsed()); + + let started = Instant::now(); + let found: i64 = probe + .query_one( + &format!("SELECT id FROM {relation} WHERE id = $1"), + &[&hot_id], + ) + .await? + .get(0); + anyhow::ensure!(found == hot_id, "hot PK {hot_id} vanished during flush"); + selects.push(started.elapsed()); + + // Pace DML so the WAL applier can drop the apply lock for + // flush finalize. A tight insert loop is lock starvation, + // not a "flush stalled the server" signal. + let started = Instant::now(); + probe + .execute( + &format!( + "INSERT INTO {relation} (id, account_id, title, qty, category) \ + VALUES ($1, 1, 'live', 1, 'hot')" + ), + &[&next_id], + ) + .await?; + inserts.push(started.elapsed()); + next_id += 1; + tokio::time::sleep(Duration::from_millis(25)).await; + } + Ok::<_, anyhow::Error>((selects, inserts, ping)) + }); + + let flushed = common::wait_for_flush_job_terminal(&db.client, &job_id).await?; + stop.store(true, Ordering::Relaxed); + let (mut selects, mut inserts, mut pings) = latency.await??; + common::wait_until_no_flush_executors(&db.client, Duration::from_secs(10)).await?; + anyhow::ensure!(flushed > 0, "queue flush archived no rows"); + anyhow::ensure!( + selects.len() >= 3 && inserts.len() >= 3, + "need overlapping samples during flush, got {} selects / {} inserts", + selects.len(), + inserts.len() + ); + + let select_median = median_duration(&mut selects); + let insert_median = median_duration(&mut inserts); + let ping_median = median_duration(&mut pings); + let select_worst = *selects.iter().max().expect("non-empty"); + let insert_worst = *inserts.iter().max().expect("non-empty"); + let ping_worst = *pings.iter().max().expect("non-empty"); + common::log_always(format!( + "concurrent during flush: select median={select_median:?} worst={select_worst:?} \ + (baseline median={baseline_median:?}); insert median={insert_median:?} \ + worst={insert_worst:?}; select 1 median={ping_median:?} worst={ping_worst:?}" + )); + + if ping_worst > budget.concurrent_select_max { + bail!( + "SELECT 1 during flush took {ping_worst:?}, budget {:?}", + budget.concurrent_select_max + ); + } + if select_worst > budget.concurrent_select_max { + bail!( + "hot PK SELECT during flush took {select_worst:?}, budget {:?}", + budget.concurrent_select_max + ); + } + if insert_worst > budget.concurrent_insert_max { + bail!( + "INSERT during flush took {insert_worst:?}, budget {:?}", + budget.concurrent_insert_max + ); + } + let select_allowed = baseline_median + .saturating_mul(10) + .saturating_add(Duration::from_millis(50)); + if select_median > select_allowed && select_median > Duration::from_millis(100) { + bail!( + "hot PK SELECT median during flush {select_median:?} exceeded 10× baseline {:?} + 50ms", + baseline_median + ); + } + + let after_pid = common::wal_applier_pid(&db.client).await?; + anyhow::ensure!( + after_pid == wal_pid, + "flush must not replace the WAL applier ({wal_pid} -> {after_pid})" + ); + + reset_flush_execution(&db, &dbname).await?; + } + Ok(()) +} diff --git a/tests/e2e/flush/flush_jobs_lock_and_faults.rs b/tests/e2e/flush/flush_jobs_lock_and_faults.rs index 66bf75d9..3fe9ea64 100644 --- a/tests/e2e/flush/flush_jobs_lock_and_faults.rs +++ b/tests/e2e/flush/flush_jobs_lock_and_faults.rs @@ -900,8 +900,8 @@ async fn parked_flush_fails_fast_other_table_on_apply_lock() -> Result<()> { let flush_b = connect_peer(&db).await?; let relation_b = table_b.relation.clone(); - // Product slot-lock budget is ~10s (200 × 50ms). Bound the client wait - // so a regression that blocks forever fails this suite quickly. + // Product slot-lock budget is ~10s of try-lock polling. Bound the client + // wait so a regression that blocks forever fails this suite quickly. let started = std::time::Instant::now(); let job_b: String = tokio::time::timeout(Duration::from_secs(15), async { flush_b diff --git a/tests/e2e/flush/flush_queue_starvation.rs b/tests/e2e/flush/flush_queue_starvation.rs new file mode 100644 index 00000000..bcdd7a9a --- /dev/null +++ b/tests/e2e/flush/flush_queue_starvation.rs @@ -0,0 +1,149 @@ +//! Flush-queue liveness when early candidates are temporarily unclaimable. + +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::common; + +const TABLE_JOB_LOCK_NAMESPACE: i64 = 0x4b54_4a42; +const BUSY_CANDIDATES: usize = 16; + +fn table_job_lock_key(table_oid: u32) -> i64 { + (TABLE_JOB_LOCK_NAMESPACE << 32) | i64::from(table_oid) +} + +/// A lockable job beyond the executor's first candidate page must make progress. +/// +/// The first 16 table locks stay held until candidate 17 reaches a terminal +/// state. This catches fixed-page head-of-line blocking in queue selection. +#[tokio::test] +async fn seventeenth_candidate_is_not_starved_by_busy_first_page() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .context("PostgreSQL target")?; + let db = common::TestDb::start(target, "candidate_page_starvation").await?; + let dbname: String = db + .client + .query_one("SELECT current_database()::text", &[]) + .await? + .get(0); + + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" SET koldstore.flush_execution = 'queue'; + ALTER DATABASE "{dbname}" SET koldstore.max_parallel_flush_jobs = 1; + ALTER DATABASE "{dbname}" SET koldstore.min_max_rows_per_file = 1; + SET koldstore.flush_execution = 'queue'; + SET koldstore.min_max_rows_per_file = 1; + "# + )) + .await?; + + let mut relations = Vec::with_capacity(BUSY_CANDIDATES + 1); + let mut table_oids = Vec::with_capacity(BUSY_CANDIDATES + 1); + for index in 0..=BUSY_CANDIDATES { + let table_name = format!("{}_candidate_{index:02}", db.schema); + let table = db.create_indexed_items_table(&table_name, 16).await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 2, + min_flush_rows => 1, + max_rows_per_file => 4, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[&table.relation, &db.storage_name], + ) + .await?; + let oid: i64 = db + .client + .query_one("SELECT $1::text::regclass::oid::bigint", &[&table.relation]) + .await? + .get(0); + relations.push(table.relation); + table_oids.push(u32::try_from(oid).context("table OID exceeds u32")?); + } + common::fence_async_mirror(&db.client).await?; + + let mut lock_holders = Vec::with_capacity(BUSY_CANDIDATES); + for oid in &table_oids[..BUSY_CANDIDATES] { + let peer = common::connect(&db.target).await?; + let key = table_job_lock_key(*oid); + peer.query_one("SELECT pg_advisory_lock($1::bigint)", &[&key]) + .await?; + lock_holders.push((peer, key)); + } + + // Publish one queue generation only after all 17 jobs and their stable sort + // order exist. Candidate 17 is deliberately the last row in that order. + let mut enqueue_client = common::connect(&db.target).await?; + let transaction = enqueue_client.transaction().await?; + let mut job_ids = Vec::with_capacity(relations.len()); + for (index, relation) in relations.iter().enumerate() { + let job_id: String = transaction + .query_one( + r#" + SELECT koldstore.enqueue_flush_job( + table_name => $1::text::regclass, + force => true + )::text + "#, + &[relation], + ) + .await? + .get(0); + transaction + .execute( + r#" + UPDATE koldstore.jobs + SET available_at = now() - interval '1 minute' + + ($2::bigint * interval '1 millisecond'), + updated_at = now() - interval '1 minute' + + ($2::bigint * interval '1 millisecond') + WHERE id = $1::text::uuid + "#, + &[&job_id, &i64::try_from(index).context("candidate index")?], + ) + .await?; + job_ids.push(job_id); + } + transaction.commit().await?; + + let seventeenth = &job_ids[BUSY_CANDIDATES]; + let result = tokio::time::timeout( + Duration::from_secs(5), + common::wait_for_flush_job_terminal(&db.client, seventeenth), + ) + .await; + + for (peer, key) in lock_holders { + let _ = peer + .query_one("SELECT pg_advisory_unlock($1::bigint)", &[&key]) + .await; + } + + let flushed = result + .map_err(|_| anyhow::anyhow!("candidate 17 starved behind the fixed 16-row busy page"))??; + anyhow::ensure!(flushed > 0, "candidate 17 completed without flushing rows"); + + db.client + .batch_execute(&format!( + r#" + ALTER DATABASE "{dbname}" RESET koldstore.flush_execution; + ALTER DATABASE "{dbname}" RESET koldstore.max_parallel_flush_jobs; + ALTER DATABASE "{dbname}" RESET koldstore.min_max_rows_per_file; + "# + )) + .await + .ok(); + Ok(()) +} diff --git a/tests/e2e/flush/flush_scheduler.rs b/tests/e2e/flush/flush_scheduler.rs index 78c79c87..4ba3e3e7 100644 --- a/tests/e2e/flush/flush_scheduler.rs +++ b/tests/e2e/flush/flush_scheduler.rs @@ -21,11 +21,16 @@ async fn auto_flush_worker_flushes_without_manual_tick() -> Result<()> { common::wait_for_async_worker(&db.client).await?; insert_rows(&db.client, &relation, 1, 10).await?; - // Restart so the worker's first cadence tick sees committed over-limit rows - // (avoids racing an empty first tick against a long interval). - restart_database_worker(&db.client).await?; - - wait_for_completed_flush_jobs(&db.client, &relation, 1, SCHEDULER_DEADLINE).await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; + common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 5, + SCHEDULER_DEADLINE, + ) + .await?; reset_flush_interval(&db.client, &dbname).await?; } Ok(()) @@ -96,6 +101,7 @@ async fn set_table_auto_flush_toggles_background_scheduling() -> Result<()> { create_messages_table(&db.client, &relation).await?; manage_auto_flush(&db.client, &relation, &db.storage_name, false).await?; insert_rows(&db.client, &relation, 1, 10).await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; tokio::time::sleep(NO_FLUSH_WINDOW).await; anyhow::ensure!(completed_flush_jobs(&db.client, &relation).await? == 0); @@ -106,8 +112,15 @@ async fn set_table_auto_flush_toggles_background_scheduling() -> Result<()> { &[&relation], ) .await?; - restart_database_worker(&db.client).await?; - wait_for_completed_flush_jobs(&db.client, &relation, 1, SCHEDULER_DEADLINE).await?; + common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 5, + SCHEDULER_DEADLINE, + ) + .await?; db.client .execute( @@ -150,10 +163,16 @@ async fn flush_check_interval_seconds_is_honored_after_worker_restart() -> Resul // interval instead of waiting out a prior default-30s deadline. configure_flush_interval_and_restart_worker(&db.client, &dbname, 1).await?; insert_rows(&db.client, &relation, 1, 10).await?; - restart_database_worker(&db.client).await?; - common::fence_async_mirror(&db.client).await?; - - wait_for_completed_flush_jobs(&db.client, &relation, 1, SCHEDULER_DEADLINE).await?; + let target_lsn = common::current_wal_lsn(&db.client).await?; + common::wait_for_passive_convergence( + &db.client, + &relation, + &target_lsn, + 1, + 5, + SCHEDULER_DEADLINE, + ) + .await?; reset_flush_interval(&db.client, &dbname).await?; } Ok(()) @@ -385,6 +404,14 @@ async fn completed_flush_jobs(client: &tokio_postgres::Client, relation: &str) - WHERE table_oid = $1::text::regclass::oid AND job_type = 'flush' AND status = 'completed' + AND created_at >= COALESCE( + ( + SELECT created_at + FROM koldstore.schemas + WHERE table_oid = $1::text::regclass::oid AND active + ), + '-infinity'::timestamptz + ) "#, &[&relation], ) @@ -400,12 +427,6 @@ async fn wait_for_completed_flush_jobs( ) -> Result<()> { let started = Instant::now(); loop { - let _ = client - .query_one( - "SELECT koldstore.internal_ensure_async_mirror_worker()", - &[], - ) - .await?; if completed_flush_jobs(client, relation).await? >= min_completed { return Ok(()); } diff --git a/tests/e2e/flush/mod.rs b/tests/e2e/flush/mod.rs index 07b1d993..f2a0a8d5 100644 --- a/tests/e2e/flush/mod.rs +++ b/tests/e2e/flush/mod.rs @@ -1,10 +1,13 @@ //! Flush and cold-publish E2E category. mod flush_async_prune_race; +mod flush_autonomous; mod flush_cancel_and_drop; mod flush_complex_and_multi; mod flush_concurrent_barrier; mod flush_concurrent_load; +mod flush_contract; +mod flush_executor_footprint; mod flush_fence_failures; mod flush_hot_mirror_cleanup; mod flush_jobs_lock_and_faults; @@ -14,7 +17,9 @@ mod flush_minio; mod flush_object_outage; mod flush_policy; mod flush_queue_mode; +mod flush_queue_starvation; mod flush_recovery; mod flush_scheduler; mod flush_to_cold; pub(crate) mod harness; +mod parquet_layout_options; diff --git a/tests/e2e/flush/parquet_layout_options.rs b/tests/e2e/flush/parquet_layout_options.rs new file mode 100644 index 00000000..2345b721 --- /dev/null +++ b/tests/e2e/flush/parquet_layout_options.rs @@ -0,0 +1,116 @@ +//! E2E coverage for per-table Parquet writer layout settings. + +use anyhow::Result; + +use crate::common; + +#[tokio::test] +async fn manage_table_layout_options_control_future_parquet_flushes() -> Result<()> { + common::require_pgrx_server().await?; + let target = common::scenario_pg_matrix() + .into_iter() + .next() + .expect("at least one pgrx PostgreSQL target"); + let db = common::TestDb::start(target, "parquet_layout_options").await?; + let relation = db.relation("layout_items"); + db.client + .batch_execute(&format!( + "CREATE TABLE {relation} (id bigint PRIMARY KEY, payload text NOT NULL); \ + SET koldstore.min_max_rows_per_file = 1;" + )) + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 1, + min_flush_rows => 1, + max_rows_per_file => 16, + migration_order_by => 'id', + parquet_row_group_size => 4, + parquet_data_page_row_count_limit => 2, + parquet_bloom_filter_fpp => 0.02, + auto_flush => false + ) + "#, + &[&relation, &db.storage_name], + ) + .await?; + db.client + .batch_execute(&format!( + "INSERT INTO {relation} (id, payload) \ + SELECT gs, repeat('payload-', 64) || gs::text FROM generate_series(1, 16) AS gs;" + )) + .await?; + common::fence_async_mirror(&db.client).await?; + assert_eq!(db.flush_table_with_force(&relation, true).await?, 16); + + let options = db + .client + .query_one( + r#" + SELECT options::text + FROM koldstore.schemas + WHERE table_oid = $1::text::regclass::oid AND active + "#, + &[&relation], + ) + .await? + .get::<_, String>(0) + .parse::()?; + assert_eq!(options["parquet_row_group_size"], 4); + assert_eq!(options["parquet_data_page_row_count_limit"], 2); + assert_eq!(options["parquet_bloom_filter_fpp"], 0.02); + assert_eq!( + options["cold_metadata"]["stats_columns"][0]["name"], "id", + "primary key must retain automatic pruning metadata" + ); + assert_eq!( + options["cold_metadata"]["bloom_filter_columns"][0]["name"], "id", + "primary key must retain automatic Bloom metadata" + ); + + let first_row_group_count: i32 = db + .client + .query_one( + "SELECT row_group_count FROM koldstore.cold_segments \ + WHERE table_oid = $1::text::regclass::oid AND status = 'active' \ + ORDER BY batch_number LIMIT 1", + &[&relation], + ) + .await? + .get(0); + assert_eq!(first_row_group_count, 4); + + db.client + .batch_execute(&format!( + "ALTER TABLE {relation} SET (\ + koldstore_parquet_row_group_size = 8, \ + koldstore_parquet_data_page_row_count_limit = 4, \ + koldstore_parquet_bloom_filter_fpp = 0.03\ + )" + )) + .await?; + db.client + .batch_execute(&format!( + "INSERT INTO {relation} (id, payload) \ + SELECT gs, repeat('new-payload-', 64) || gs::text FROM generate_series(17, 24) AS gs;" + )) + .await?; + common::fence_async_mirror(&db.client).await?; + assert_eq!(db.flush_table_with_force(&relation, true).await?, 8); + let row_group_count: i32 = db + .client + .query_one( + "SELECT row_group_count FROM koldstore.cold_segments \ + WHERE table_oid = $1::text::regclass::oid AND status = 'active' \ + ORDER BY batch_number DESC LIMIT 1", + &[&relation], + ) + .await? + .get(0); + assert_eq!(row_group_count, 1, "ALTER must apply to future flushes"); + Ok(()) +} diff --git a/tests/e2e/merge/merge_scan_outage.rs b/tests/e2e/merge/merge_scan_outage.rs index b3f3393a..374b89a6 100644 --- a/tests/e2e/merge/merge_scan_outage.rs +++ b/tests/e2e/merge/merge_scan_outage.rs @@ -1,23 +1,6 @@ use crate::common; use anyhow::Result; -use koldstore::merge_scan::exec::{begin_merge_scan, ColdAvailability, MergeScanError}; - -#[test] -fn merge_scan_outage_requires_error_not_partial_hot_only_results() { - common::require_pgrx_server_sync() - .expect("E2E tests require a running pgrx PostgreSQL server with koldstore installed"); - - let error = begin_merge_scan( - 42, - vec!["app/items/batch-0.parquet".to_string()], - ColdAvailability::Unavailable, - ) - .unwrap_err(); - - assert_eq!(error, MergeScanError::ColdRequiredUnavailable); - assert!(error.to_string().contains("cold data required")); -} #[tokio::test] async fn dirty_manifest_outage_state_uses_partial_index_on_pgrx() -> Result<()> { diff --git a/tests/e2e/merge/merge_scan_results.rs b/tests/e2e/merge/merge_scan_results.rs index d11b7718..fb80debf 100644 --- a/tests/e2e/merge/merge_scan_results.rs +++ b/tests/e2e/merge/merge_scan_results.rs @@ -1,10 +1,8 @@ use crate::common; use anyhow::Result; -use koldstore::merge_scan::exec::{ - execute_merge_scan, execute_merge_scan_with_filters, FilterPlan, -}; use koldstore_common::{ColdRow, HotRow, LogicalPk, PkColumn, RowImage, SeqId}; +use koldstore_merge::{resolve_rows_owned, ResolvedRow}; use serde_json::json; fn pk(id: i64) -> LogicalPk { @@ -44,25 +42,33 @@ fn cold_deleted(id: i64, seq: i64) -> ColdRow { } } +fn row_matches_json_eq(row: &ResolvedRow, column: &str, expected: &str) -> bool { + row.row_image.get(column).is_some_and(|value| match value { + koldstore_common::CellValue::Utf8(text) => text == expected, + other => other.display_text() == expected, + }) +} + #[test] fn merge_scan_results_resolve_hot_winner_and_tombstone_masking() { common::require_pgrx_server_sync() .expect("E2E tests require a running pgrx PostgreSQL server with koldstore installed"); - let result = execute_merge_scan( - vec![hot(1, 20, false, "hot-winner"), hot(2, 21, true, "deleted")], - vec![cold(1, 10, "older-cold"), cold(2, 10, "masked-cold")], - ) - .unwrap(); + let hot_rows = vec![hot(1, 20, false, "hot-winner"), hot(2, 21, true, "deleted")]; + let cold_rows = vec![cold(1, 10, "older-cold"), cold(2, 10, "masked-cold")]; + let hot_rows_seen = hot_rows.len(); + let cold_rows_seen = cold_rows.len(); + let tombstones_masked = hot_rows.iter().filter(|row| row.deleted).count(); + let rows = resolve_rows_owned(hot_rows, cold_rows); - assert_eq!(result.rows.len(), 1); + assert_eq!(rows.len(), 1); assert_eq!( - result.rows[0].row_image.to_json(), + rows[0].row_image.to_json(), json!({"id": 1, "body": "hot-winner"}) ); - assert_eq!(result.hot_rows_seen, 2); - assert_eq!(result.cold_rows_seen, 2); - assert_eq!(result.tombstones_masked, 1); + assert_eq!(hot_rows_seen, 2); + assert_eq!(cold_rows_seen, 2); + assert_eq!(tombstones_masked, 1); } #[test] @@ -70,17 +76,18 @@ fn merge_scan_results_apply_residual_filters_after_winner_resolution() { common::require_pgrx_server_sync() .expect("E2E tests require a running pgrx PostgreSQL server with koldstore installed"); - let result = execute_merge_scan_with_filters( + let mut rows = resolve_rows_owned( vec![hot(1, 20, false, "hot-winner")], vec![cold(1, 10, "older-cold"), cold(2, 11, "cold-only")], - FilterPlan::new().with_required_json_eq("body", "hot-winner"), - ) - .unwrap(); + ); + let before_residual = rows.len(); + rows.retain(|row| row_matches_json_eq(row, "body", "hot-winner")); + let filtered_rows = before_residual.saturating_sub(rows.len()); - assert_eq!(result.rows.len(), 1); - assert_eq!(result.filtered_rows, 1); + assert_eq!(rows.len(), 1); + assert_eq!(filtered_rows, 1); assert_eq!( - result.rows[0].row_image.to_json(), + rows[0].row_image.to_json(), json!({"id": 1, "body": "hot-winner"}) ); } @@ -90,19 +97,16 @@ fn merge_scan_results_apply_cold_delete_markers_and_newer_reinserts() { common::require_pgrx_server_sync() .expect("E2E tests require a running pgrx PostgreSQL server with koldstore installed"); - let deleted = - execute_merge_scan(vec![], vec![cold(1, 10, "old"), cold_deleted(1, 20)]).unwrap(); - assert!(deleted.rows.is_empty()); - assert_eq!(deleted.tombstones_masked, 0); + let deleted = resolve_rows_owned(vec![], vec![cold(1, 10, "old"), cold_deleted(1, 20)]); + assert!(deleted.is_empty()); - let reinserted = execute_merge_scan( + let reinserted = resolve_rows_owned( vec![hot(1, 30, false, "reinserted")], vec![cold(1, 10, "old"), cold_deleted(1, 20)], - ) - .unwrap(); - assert_eq!(reinserted.rows.len(), 1); + ); + assert_eq!(reinserted.len(), 1); assert_eq!( - reinserted.rows[0].row_image.to_json(), + reinserted[0].row_image.to_json(), json!({"id": 1, "body": "reinserted"}) ); } diff --git a/tests/e2e/merge/merge_scan_teardown_crash_safe.rs b/tests/e2e/merge/merge_scan_teardown_crash_safe.rs new file mode 100644 index 00000000..91c18333 --- /dev/null +++ b/tests/e2e/merge/merge_scan_teardown_crash_safe.rs @@ -0,0 +1,146 @@ +//! Regression: KoldMergeScan teardown must not abort the backend. +//! +//! Docker load testing showed that after a managed hot+cold `count(*)`: +//! - fail-closed on `koldstore.max_merge_seen_keys`, or +//! - a successful unbounded scan (`max_merge_seen_keys = 0`), +//! +//! the **next statement on the same backend** could hit glibc +//! `double free or corruption` and terminate with signal 6. +//! +//! Root cause: portal ERROR skips `EndCustomScan` while `SCAN_STATES` still +//! owned a deleted AllocSet; success path also cleared slots after ScanMemory +//! drop. This e2e keeps one TCP session and asserts follow-up queries stay live. + +use anyhow::{bail, Context, Result}; + +use crate::common; + +#[tokio::test] +async fn merge_scan_followup_query_survives_seen_key_error_and_unbounded_count() -> Result<()> { + common::require_pgrx_server().await?; + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "merge_teardown_safe").await?; + let table = db.create_indexed_items_table("teardown_items", 500).await?; + // Low hot_row_limit so force flush publishes cold and count(*) merges. + db.client + .batch_execute("SET koldstore.min_max_rows_per_file = 1") + .await + .context("allow small max_rows_per_file for test")?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 10, + min_flush_rows => 1, + max_rows_per_file => 100, + migration_order_by => 'id', + auto_flush => false + ) + "#, + &[&table.relation, &db.storage_name], + ) + .await + .context("manage_table with low hot_row_limit")?; + common::fence_async_mirror(&db.client).await?; + + let flushed = db.flush_table_with_force(&table.relation, true).await?; + anyhow::ensure!( + flushed >= 400, + "expected cold archive before teardown probes, rows_flushed={flushed}" + ); + common::assert_no_active_jobs(&db.client, &table.relation).await?; + + let hot = common::hot_row_count(&db.client, &table.relation).await?; + anyhow::ensure!( + hot <= 10, + "expected hot prune to hot_row_limit after force flush, hot={hot}" + ); + + // --- Path A: fail-closed seen-key ERROR, then same-session follow-ups --- + db.client + .batch_execute("SET koldstore.max_merge_seen_keys = 50") + .await + .context("set low seen-key cap")?; + + let limited = db + .client + .query_one(&format!("SELECT count(*) FROM {}", table.relation), &[]) + .await; + let err = limited + .err() + .context("count(*) must ERROR when seen-key cap is exceeded")?; + let err_text = err + .as_db_error() + .map(|db| db.message().to_string()) + .unwrap_or_else(|| err.to_string()); + if !(err_text.contains("exact primary-key identities") + || err_text.contains("max_merge_seen_keys") + || err_text.contains("retained too many")) + { + bail!("unexpected seen-key failure: {err_text}"); + } + + db.client + .batch_execute("SELECT 1") + .await + .context("backend must stay alive after seen-key ERROR")?; + + let cold_pk: i64 = db + .client + .query_one( + &format!("SELECT id FROM {} WHERE id = 1", table.relation), + &[], + ) + .await + .context("point lookup after seen-key ERROR must not abort backend")? + .get(0); + anyhow::ensure!(cold_pk == 1); + + // --- Path B: unbounded merge count, then same-session follow-ups --- + db.client + .batch_execute("SET koldstore.max_merge_seen_keys = 0") + .await + .context("disable seen-key cap")?; + + let total: i64 = db + .client + .query_one( + &format!("SELECT count(*)::bigint FROM {}", table.relation), + &[], + ) + .await + .context("unbounded hot+cold count(*)")? + .get(0); + anyhow::ensure!( + total == 500, + "unbounded count must see all managed rows, got {total}" + ); + + let followup: i64 = db + .client + .query_one( + &format!("SELECT id FROM {} WHERE id = 42", table.relation), + &[], + ) + .await + .context( + "follow-up query after unbounded count must not abort backend \ + (regression: double free / signal 6)", + )? + .get(0); + anyhow::ensure!(followup == 42); + + db.client + .batch_execute("SELECT 1") + .await + .context("backend must stay alive after unbounded count teardown")?; + + db.client + .batch_execute("RESET koldstore.max_merge_seen_keys") + .await + .context("reset seen-key GUC")?; + } + Ok(()) +} diff --git a/tests/e2e/merge/mod.rs b/tests/e2e/merge/mod.rs index e4ae2981..7a7d9acf 100644 --- a/tests/e2e/merge/mod.rs +++ b/tests/e2e/merge/mod.rs @@ -3,6 +3,7 @@ mod merge_scan_matrix; mod merge_scan_outage; mod merge_scan_results; +mod merge_scan_teardown_crash_safe; mod order_column_cold_index; mod ordered_limit_after_flush; mod preload_fresh_session; diff --git a/tests/e2e/merge/order_column_cold_index.rs b/tests/e2e/merge/order_column_cold_index.rs index 75061e17..78826e45 100644 --- a/tests/e2e/merge/order_column_cold_index.rs +++ b/tests/e2e/merge/order_column_cold_index.rs @@ -4,6 +4,83 @@ use crate::common; use anyhow::Result; +/// A migration order also supplies the cold segment order when no explicit +/// segment order is configured. Exercise the existing-table initialization +/// path so the mirror must populate its encoded order key before the flush. +#[tokio::test] +async fn migration_order_defaults_to_segment_order_for_existing_table() -> Result<()> { + common::require_pgrx_server().await?; + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "migration_order_default").await?; + let relation = db.relation("events"); + db.client + .batch_execute(&format!( + r#" + CREATE TABLE {relation} ( + id bigint PRIMARY KEY, + event_time timestamptz NOT NULL, + payload text NOT NULL + ); + CREATE INDEX events_event_time_idx ON {relation} (event_time DESC); + INSERT INTO {relation} (id, event_time, payload) VALUES + (1, timestamptz '2026-01-01 00:00:00+00', 'old'), + (2, timestamptz '2026-01-02 00:00:00+00', 'new'); + "# + )) + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 1, + min_flush_rows => 1, + max_rows_per_file => 1000, + migration_order_by => 'event_time' + ) + "#, + &[&relation, &db.storage_name], + ) + .await?; + + let flushed = force_flush(&db, &relation).await?; + anyhow::ensure!( + flushed >= 2, + "expected existing rows to flush, got {flushed}" + ); + + let order_column_id: Option = db + .client + .query_one( + r#" + SELECT (options->>'segment_order_column_id')::bigint + FROM koldstore.schemas + WHERE table_oid = $1::text::regclass::oid + AND active + "#, + &[&relation], + ) + .await? + .get(0); + anyhow::ensure!( + order_column_id == Some(2), + "migration order must persist as segment order column 2, got {order_column_id:?}" + ); + + let plan = common::explain_analyze( + &db.client, + &format!("SELECT payload FROM {relation} ORDER BY event_time DESC LIMIT 1"), + ) + .await?; + anyhow::ensure!( + plan.contains("Strategy: Ordered Progressive"), + "migration-order default must use the ordered merge path:\n{plan}" + ); + } + Ok(()) +} + /// Flush two time-disjoint waves, then prove SQL segment-index pruning for /// lower-only, upper-only, and bounded predicates independently. #[tokio::test] @@ -187,10 +264,7 @@ async fn async_order_column_retains_mirror_order_key() -> Result<()> { .await?; common::fence_async_mirror(&db.client).await?; - let mirror = format!( - "koldstore.{}__cl", - relation.rsplit('.').next().unwrap_or(relation.as_str()) - ); + let mirror = common::change_log_mirror_relation(&relation); let before: Vec = db .client .query_one(&format!("SELECT order_key FROM {mirror} WHERE id = 1"), &[]) @@ -440,10 +514,7 @@ async fn setup_order_table(db: &common::TestDb, table: &str) -> Result { AND column_name = 'order_key' ) "#, - &[&format!( - "{}__cl", - relation.rsplit('.').next().unwrap_or(relation.as_str()) - )], + &[&common::change_log_mirror_relation_name(&relation)], ) .await? .get(0); diff --git a/tests/e2e/merge/user_scope_cold_pruning.rs b/tests/e2e/merge/user_scope_cold_pruning.rs index e453b441..4aa36991 100644 --- a/tests/e2e/merge/user_scope_cold_pruning.rs +++ b/tests/e2e/merge/user_scope_cold_pruning.rs @@ -1,7 +1,6 @@ use crate::common; use anyhow::Result; -use koldstore::merge_scan::exec::{begin_merge_scan_with_plan, ColdAvailability}; use koldstore::merge_scan::plan::{MergeScanPlan, SegmentHint}; use koldstore_common::{ScopeKey, SeqId}; @@ -31,14 +30,28 @@ fn user_scope_cold_pruning_filters_segments_before_stream_open() { }, ]; - let state = begin_merge_scan_with_plan(&plan, ColdAvailability::Available).unwrap(); + // Scoped plans must open only matching segment hints before any cold stream + // is created. Fail-closed outage coverage lives in cold_reads=off e2e tests. + let visible: Vec<&SegmentHint> = plan + .segment_hints + .iter() + .filter(|hint| hint.scope_key == plan.scope_key) + .collect(); assert_eq!( - state.visible_segments, + visible + .iter() + .map(|hint| hint.object_path.as_str()) + .collect::>(), vec!["app/notes/user-a/batch-1.parquet"] ); - assert_eq!(state.selected_row_groups, vec![0]); - assert_eq!(state.resources.object_store_handles, 1); + assert_eq!( + visible + .iter() + .flat_map(|hint| hint.selected_row_groups.iter().copied()) + .collect::>(), + vec![0] + ); } /// Text scope equality remains correct when Sort Key V1 cannot index the scope diff --git a/tests/e2e/migrate/demigrate_matrix.rs b/tests/e2e/migrate/demigrate_matrix.rs index f5215cd8..531d8107 100644 --- a/tests/e2e/migrate/demigrate_matrix.rs +++ b/tests/e2e/migrate/demigrate_matrix.rs @@ -50,7 +50,7 @@ async fn demigrate_catalog_deactivation_cancels_jobs_and_preserves_heap_rows_on_ .get::<_, i64>(0); assert_eq!(system_columns, 0); - let mirror = format!("koldstore.{}__cl", table.table_name); + let mirror = common::change_log_mirror_relation(&table.relation); let mirror_exists = db .client .query_one("SELECT to_regclass($1)::oid IS NOT NULL", &[&mirror]) diff --git a/tests/e2e/migrate/greenfield_matrix.rs b/tests/e2e/migrate/greenfield_matrix.rs index 562b5c2b..d752c5c4 100644 --- a/tests/e2e/migrate/greenfield_matrix.rs +++ b/tests/e2e/migrate/greenfield_matrix.rs @@ -133,8 +133,7 @@ async fn run_greenfield_scenario( .await?; } - let source_table_name = relation.rsplit('.').next().unwrap_or(&relation); - let mirror_relation = format!("koldstore.{source_table_name}__cl"); + let mirror_relation = common::change_log_mirror_relation(&relation); common::assert_system_columns_absent(client, &relation).await?; common::assert_change_log_mirror_exists(client, &mirror_relation).await?; common::assert_primary_key_columns_match(client, &relation, &mirror_relation).await?; diff --git a/tests/e2e/migrate/migrate_existing_matrix.rs b/tests/e2e/migrate/migrate_existing_matrix.rs index 8d0f3042..edf79b57 100644 --- a/tests/e2e/migrate/migrate_existing_matrix.rs +++ b/tests/e2e/migrate/migrate_existing_matrix.rs @@ -131,7 +131,7 @@ async fn run_existing_table_scenario( .get::<_, i64>(0); assert_eq!(system_columns, 0); - let mirror_relation = format!("koldstore.{}_pg{}__cl", scenario.table_name, pg_version); + let mirror_relation = common::change_log_mirror_relation(&relation); let mirror_primary_key = client .query_one( r#" diff --git a/tests/e2e/suite/cms_wordpress_journey.rs b/tests/e2e/suite/cms_wordpress_journey.rs new file mode 100644 index 00000000..432deaee --- /dev/null +++ b/tests/e2e/suite/cms_wordpress_journey.rs @@ -0,0 +1,544 @@ +//! WordPress/CMS-shaped first-time operator journey. +//! +//! Mimics someone managing ~10 classic CMS tables (no FKs / no secondary +//! UNIQUE — KoldStore flush policy), seeding realistic content with +//! `timestamptz` values, inspecting `table_status`, running GROUP BY + joins, +//! flushing into cold, then re-checking exact timestamps and editorial queries. +//! +//! Short paced sleeps keep the scenario from looking like a blast test; override +//! with `KOLDSTORE_CMS_E2E_PAUSE_MS` (default 150). + +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::time::sleep; + +use crate::common; + +const DEFAULT_PAUSE_MS: u64 = 150; + +fn pause_ms() -> u64 { + std::env::var("KOLDSTORE_CMS_E2E_PAUSE_MS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(DEFAULT_PAUSE_MS) +} + +async fn human_pause(label: &str) { + let ms = pause_ms(); + if ms == 0 { + return; + } + common::log_always(format!("cms pause {ms}ms — {label}")); + sleep(Duration::from_millis(ms)).await; +} + +const TABLES: &[(&str, &str)] = &[ + ("wp_users", "id"), + ("wp_usermeta", "umeta_id"), + ("wp_posts", "id"), + ("wp_postmeta", "meta_id"), + ("wp_comments", "comment_id"), + ("wp_commentmeta", "meta_id"), + ("wp_terms", "term_id"), + ("wp_term_taxonomy", "term_taxonomy_id"), + ("wp_term_relationships", "object_id"), + ("wp_options", "option_id"), +]; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn wordpress_cms_ten_tables_joins_timestamps_survive_flush() -> Result<()> { + common::require_pgrx_server().await?; + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "cms_wp").await?; + let schema = db.schema.clone(); + + human_pause("operator reads quickstart, creates CMS schema").await; + create_cms_schema(&db, &schema).await?; + + human_pause("manages each table like a checklist").await; + for (table, order_by) in TABLES { + let relation = format!("{schema}.{table}"); + manage_cms_table(&db, &relation, order_by).await?; + sleep(Duration::from_millis(pause_ms() / 3)).await; + } + common::wait_for_async_worker(&db.client).await?; + + human_pause("checks table_status right after migrate").await; + let posts = format!("{schema}.wp_posts"); + let status = common::table_status(&db.client, &posts).await?; + anyhow::ensure!( + status.pending_jobs == 0, + "pending jobs after manage: {:?}", + status + ); + let healthy: bool = db + .client + .query_one( + "SELECT (koldstore.async_mirror_status()->>'healthy')::boolean", + &[], + ) + .await? + .get(0); + anyhow::ensure!(healthy, "async mirror should be healthy after manage"); + + human_pause("loads realistic seed content").await; + seed_cms_content(&db, &schema).await?; + common::fence_async_mirror(&db.client).await?; + human_pause("coffee while mirror catches up").await; + + assert_post_timestamps( + &db, + &schema, + 10, + "Hello from Amman", + "2024-06-01 10:00:00", + "2024-06-01 10:15:00", + ) + .await?; + assert_author_group_by(&db, &schema, "Guest Editor:1,Maya Chen:1,Omar Haddad:1").await?; + assert_category_join_rows(&db, &schema, 4).await?; + assert_first_comment_ts(&db, &schema, "hello-from-amman", "2024-06-01 11:00:00").await?; + + let hot_before = common::table_status(&db.client, &posts).await?.hot_rows; + anyhow::ensure!( + hot_before >= 5, + "expected hot posts before flush, got {hot_before}" + ); + + human_pause("flushes busy editorial tables into cold").await; + for table in [ + "wp_posts", + "wp_comments", + "wp_users", + "wp_postmeta", + "wp_term_relationships", + ] { + let relation = format!("{schema}.{table}"); + let flushed = db.flush_table_with_force(&relation, true).await?; + anyhow::ensure!(flushed > 0, "{relation} flush returned {flushed}"); + sleep(Duration::from_millis(pause_ms() / 2)).await; + } + + human_pause("reloads dashboard after cold archive settles").await; + assert_post_timestamps( + &db, + &schema, + 10, + "Hello from Amman", + "2024-06-01 10:00:00", + "2024-06-01 10:15:00", + ) + .await?; + assert_author_group_by(&db, &schema, "Guest Editor:1,Maya Chen:1,Omar Haddad:1").await?; + assert_first_comment_ts(&db, &schema, "hello-from-amman", "2024-06-01 11:00:00").await?; + + let after = common::table_status(&db.client, &posts).await?; + anyhow::ensure!( + after.cold_row_count >= 1, + "expected cold rows after flush: {:?}", + after + ); + anyhow::ensure!( + after.cold_segment_count >= 1, + "expected cold segments after flush: {:?}", + after + ); + anyhow::ensure!( + after.manifest_state.as_deref() == Some("in_sync"), + "manifest_state after flush: {:?}", + after.manifest_state + ); + + human_pause("editor publishes a post after archive exists").await; + db.client + .batch_execute(&format!( + "INSERT INTO {schema}.wp_posts ( + id, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, + post_status, post_name, post_modified, post_modified_gmt, post_type, comment_count + ) VALUES ( + 15, 2, '2024-09-01 15:30:00+00', '2024-09-01 15:30:00+00', + 'A fresh post written after the archive flush.', + 'After the flush', 'Post-flush editorial', + 'publish', 'after-the-flush', '2024-09-01 15:30:00+00', '2024-09-01 15:30:00+00', + 'post', 0 + ); + INSERT INTO {schema}.wp_term_relationships VALUES (15, 2, 0);" + )) + .await?; + common::fence_async_mirror(&db.client).await?; + human_pause("checks the new post still shows the exact timestamp").await; + + let new_ts: String = db + .client + .query_one( + &format!( + "SELECT to_char(post_date AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') + FROM {schema}.wp_posts WHERE post_name = 'after-the-flush'" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!( + new_ts == "2024-09-01 15:30:00", + "new post timestamp mismatch: {new_ts}" + ); + let published: i64 = db + .client + .query_one( + &format!("SELECT count(*) FROM {schema}.wp_posts WHERE post_status = 'publish'"), + &[], + ) + .await? + .get(0); + anyhow::ensure!( + published == 5, + "published posts after new insert: {published}" + ); + + // Mixed hot+cold join must still return both archived and fresh rows. + let titles: Vec = db + .client + .query( + &format!( + "SELECT p.post_title + FROM {schema}.wp_posts p + JOIN {schema}.wp_users u ON u.id = p.post_author + WHERE p.id IN (10, 15) + ORDER BY p.id" + ), + &[], + ) + .await? + .into_iter() + .map(|row| row.get(0)) + .collect(); + anyhow::ensure!( + titles + == [ + "Hello from Amman".to_string(), + "After the flush".to_string() + ], + "mixed hot+cold join titles: {titles:?}" + ); + } + + Ok(()) +} + +async fn create_cms_schema(db: &common::TestDb, schema: &str) -> Result<()> { + // Classic WordPress: application-level integrity, no Postgres FKs / secondary UNIQUE + // (flush cannot preserve those globally across hot+cold). + db.client + .batch_execute(&format!( + r#" + CREATE TABLE {schema}.wp_users ( + id bigint PRIMARY KEY, + user_login text NOT NULL, + user_email text NOT NULL, + display_name text NOT NULL, + user_registered timestamptz NOT NULL, + user_status int NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_usermeta ( + umeta_id bigint PRIMARY KEY, + user_id bigint NOT NULL, + meta_key text NOT NULL, + meta_value text NOT NULL + ); + CREATE TABLE {schema}.wp_posts ( + id bigint PRIMARY KEY, + post_author bigint NOT NULL, + post_date timestamptz NOT NULL, + post_date_gmt timestamptz NOT NULL, + post_content text NOT NULL, + post_title text NOT NULL, + post_excerpt text NOT NULL DEFAULT '', + post_status text NOT NULL, + post_name text NOT NULL, + post_modified timestamptz NOT NULL, + post_modified_gmt timestamptz NOT NULL, + post_type text NOT NULL, + comment_count bigint NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_postmeta ( + meta_id bigint PRIMARY KEY, + post_id bigint NOT NULL, + meta_key text NOT NULL, + meta_value text NOT NULL + ); + CREATE TABLE {schema}.wp_comments ( + comment_id bigint PRIMARY KEY, + comment_post_id bigint NOT NULL, + comment_author text NOT NULL, + comment_author_email text NOT NULL, + comment_date timestamptz NOT NULL, + comment_date_gmt timestamptz NOT NULL, + comment_content text NOT NULL, + user_id bigint NOT NULL DEFAULT 0, + comment_approved text NOT NULL DEFAULT '1' + ); + CREATE TABLE {schema}.wp_commentmeta ( + meta_id bigint PRIMARY KEY, + comment_id bigint NOT NULL, + meta_key text NOT NULL, + meta_value text NOT NULL + ); + CREATE TABLE {schema}.wp_terms ( + term_id bigint PRIMARY KEY, + name text NOT NULL, + slug text NOT NULL + ); + CREATE TABLE {schema}.wp_term_taxonomy ( + term_taxonomy_id bigint PRIMARY KEY, + term_id bigint NOT NULL, + taxonomy text NOT NULL, + description text NOT NULL DEFAULT '', + parent bigint NOT NULL DEFAULT 0, + count bigint NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_term_relationships ( + object_id bigint NOT NULL, + term_taxonomy_id bigint NOT NULL, + term_order int NOT NULL DEFAULT 0, + PRIMARY KEY (object_id, term_taxonomy_id) + ); + CREATE TABLE {schema}.wp_options ( + option_id bigint PRIMARY KEY, + option_name text NOT NULL, + option_value text NOT NULL, + autoload text NOT NULL DEFAULT 'yes' + ); + CREATE INDEX {schema}_wp_options_name_idx ON {schema}.wp_options (option_name); + "# + )) + .await + .context("create CMS schema")?; + Ok(()) +} + +async fn manage_cms_table(db: &common::TestDb, relation: &str, order_by: &str) -> Result<()> { + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 200, + min_flush_rows => 1, + max_rows_per_file => 1000, + auto_flush => false, + migration_order_by => $3 + ) + "#, + &[&relation, &db.storage_name, &order_by], + ) + .await + .with_context(|| format!("manage {relation}"))?; + common::assert_catalog_has_active_schema(&db.client, relation).await?; + Ok(()) +} + +async fn seed_cms_content(db: &common::TestDb, schema: &str) -> Result<()> { + db.client + .batch_execute(&format!( + r#" + INSERT INTO {schema}.wp_users (id, user_login, user_email, display_name, user_registered, user_status) VALUES + (1, 'admin', 'admin@example.com', 'Site Admin', '2024-01-15 09:00:00+00', 0), + (2, 'maya', 'maya@example.com', 'Maya Chen', '2024-03-02 14:22:00+00', 0), + (3, 'omar', 'omar@example.com', 'Omar Haddad', '2024-05-18 11:05:00+00', 0), + (4, 'guest_editor', 'editor@example.com', 'Guest Editor', '2024-07-01 08:30:00+00', 0); + + INSERT INTO {schema}.wp_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES + (1, 1, 'nickname', 'admin'), + (2, 2, 'nickname', 'maya'), + (3, 2, 'description', 'Writes about databases and cities.'), + (4, 3, 'nickname', 'omar'), + (5, 4, 'nickname', 'guest'); + + INSERT INTO {schema}.wp_posts ( + id, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, + post_status, post_name, post_modified, post_modified_gmt, post_type, comment_count + ) VALUES + (10, 2, '2024-06-01 10:00:00+00', '2024-06-01 10:00:00+00', + 'Cold mornings on the Amman hills.', 'Hello from Amman', 'First post', + 'publish', 'hello-from-amman', '2024-06-01 10:15:00+00', '2024-06-01 10:15:00+00', 'post', 2), + (11, 3, '2024-06-10 16:45:00+00', '2024-06-10 16:45:00+00', + 'Hot/cold storage for editorial archives.', 'Hot and Cold Archives', 'Primer', + 'publish', 'hot-and-cold-archives', '2024-06-11 09:00:00+00', '2024-06-11 09:00:00+00', 'post', 1), + (12, 2, '2024-07-04 12:00:00+00', '2024-07-04 12:00:00+00', + 'Draft notes.', 'Newsletter draft', '', + 'draft', 'newsletter-draft', '2024-07-04 12:00:00+00', '2024-07-04 12:00:00+00', 'post', 0), + (13, 1, '2024-08-01 08:00:00+00', '2024-08-01 08:00:00+00', + 'Welcome.', 'Home', 'Site home', + 'publish', 'home', '2024-08-01 08:00:00+00', '2024-08-01 08:00:00+00', 'page', 0), + (14, 4, '2024-08-05 19:20:00+00', '2024-08-05 19:20:00+00', + 'Guest perspective.', 'Migrating Media Libraries', 'Guest feature', + 'publish', 'migrating-media-libraries', '2024-08-06 07:10:00+00', '2024-08-06 07:10:00+00', 'post', 3); + + INSERT INTO {schema}.wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES + (1, 10, 'views', '128'), + (2, 11, 'views', '640'), + (3, 14, 'views', '91'), + (4, 14, 'featured', '1'); + + INSERT INTO {schema}.wp_comments ( + comment_id, comment_post_id, comment_author, comment_author_email, + comment_date, comment_date_gmt, comment_content, user_id, comment_approved + ) VALUES + (100, 10, 'Sam', 'sam@example.com', '2024-06-01 11:00:00+00', '2024-06-01 11:00:00+00', + 'Loved the Amman morning detail.', 0, '1'), + (101, 10, 'Maya Chen', 'maya@example.com', '2024-06-01 12:30:00+00', '2024-06-01 12:30:00+00', + 'Thanks Sam.', 2, '1'), + (102, 11, 'Omar Haddad', 'omar@example.com', '2024-06-12 08:00:00+00', '2024-06-12 08:00:00+00', + 'Expand the flush section.', 3, '1'), + (103, 14, 'Site Admin', 'admin@example.com', '2024-08-05 20:00:00+00', '2024-08-05 20:00:00+00', + 'Great guest piece.', 1, '1'), + (104, 14, 'Reader', 'reader@example.com', '2024-08-06 09:15:00+00', '2024-08-06 09:15:00+00', + 'MinIO too?', 0, '1'), + (105, 14, 'Guest Editor', 'editor@example.com', '2024-08-06 10:00:00+00', '2024-08-06 10:00:00+00', + 'Yes — S3-compatible storage works.', 4, '1'); + + INSERT INTO {schema}.wp_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES + (1, 100, 'rating', '5'), + (2, 104, 'rating', '4'); + + INSERT INTO {schema}.wp_terms (term_id, name, slug) VALUES + (1, 'Uncategorized', 'uncategorized'), + (2, 'Engineering', 'engineering'), + (3, 'Travel', 'travel'), + (4, 'featured', 'featured'), + (5, 'postgres', 'postgres'); + + INSERT INTO {schema}.wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES + (1, 1, 'category', '', 0, 1), + (2, 2, 'category', 'Systems', 0, 2), + (3, 3, 'category', 'Places', 0, 1), + (4, 4, 'post_tag', '', 0, 1), + (5, 5, 'post_tag', '', 0, 2); + + INSERT INTO {schema}.wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES + (10, 3, 0), (10, 5, 1), + (11, 2, 0), (11, 5, 1), + (14, 2, 0), (14, 4, 1), + (13, 1, 0); + + INSERT INTO {schema}.wp_options (option_id, option_name, option_value, autoload) VALUES + (1, 'siteurl', 'https://cms.example.com', 'yes'), + (2, 'blogname', 'KoldStore Demo CMS', 'yes'), + (3, 'timezone_string', 'UTC', 'yes'), + (4, 'posts_per_page', '10', 'yes'); + "# + )) + .await + .context("seed CMS content")?; + Ok(()) +} + +async fn assert_post_timestamps( + db: &common::TestDb, + schema: &str, + id: i64, + title: &str, + post_date: &str, + modified: &str, +) -> Result<()> { + let row = db + .client + .query_one( + &format!( + "SELECT post_title, + to_char(post_date AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS'), + to_char(post_modified AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') + FROM {schema}.wp_posts WHERE id = $1" + ), + &[&id], + ) + .await?; + let got_title: String = row.get(0); + let got_date: String = row.get(1); + let got_mod: String = row.get(2); + anyhow::ensure!(got_title == title, "title: {got_title} != {title}"); + anyhow::ensure!( + got_date == post_date, + "post_date: {got_date} != {post_date}" + ); + anyhow::ensure!( + got_mod == modified, + "post_modified: {got_mod} != {modified}" + ); + Ok(()) +} + +async fn assert_author_group_by(db: &common::TestDb, schema: &str, expected: &str) -> Result<()> { + let got: String = db + .client + .query_one( + &format!( + "SELECT string_agg(display_name || ':' || published_posts::text, ',' ORDER BY display_name) + FROM ( + SELECT u.display_name, count(*)::int AS published_posts + FROM {schema}.wp_posts p + JOIN {schema}.wp_users u ON u.id = p.post_author + WHERE p.post_status = 'publish' AND p.post_type = 'post' + GROUP BY u.display_name + ) s" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!(got == expected, "author group-by: {got} != {expected}"); + Ok(()) +} + +async fn assert_category_join_rows(db: &common::TestDb, schema: &str, expected: i64) -> Result<()> { + let got: i64 = db + .client + .query_one( + &format!( + "SELECT count(*) FROM ( + SELECT p.post_title, t.name, count(c.comment_id) AS comments + FROM {schema}.wp_posts p + JOIN {schema}.wp_term_relationships tr ON tr.object_id = p.id + JOIN {schema}.wp_term_taxonomy tt + ON tt.term_taxonomy_id = tr.term_taxonomy_id AND tt.taxonomy = 'category' + JOIN {schema}.wp_terms t ON t.term_id = tt.term_id + LEFT JOIN {schema}.wp_comments c + ON c.comment_post_id = p.id AND c.comment_approved = '1' + WHERE p.post_status = 'publish' + GROUP BY p.post_title, t.name + ) q" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!(got == expected, "category join rows: {got} != {expected}"); + Ok(()) +} + +async fn assert_first_comment_ts( + db: &common::TestDb, + schema: &str, + post_name: &str, + expected: &str, +) -> Result<()> { + let got: String = db + .client + .query_one( + &format!( + "SELECT to_char(min(c.comment_date) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') + FROM {schema}.wp_comments c + JOIN {schema}.wp_posts p ON p.id = c.comment_post_id + WHERE p.post_name = $1" + ), + &[&post_name], + ) + .await? + .get(0); + anyhow::ensure!(got == expected, "first comment ts: {got} != {expected}"); + Ok(()) +} diff --git a/tests/e2e/suite/cms_wordpress_load.rs b/tests/e2e/suite/cms_wordpress_load.rs new file mode 100644 index 00000000..235f5234 --- /dev/null +++ b/tests/e2e/suite/cms_wordpress_load.rs @@ -0,0 +1,581 @@ +//! High-load WordPress/CMS operator journey under concurrent DML + flush. +//! +//! Seeds thousands of posts/comments across managed CMS tables, runs concurrent +//! writers/readers, flushes into multiple cold segments, then verifies: +//! - exact timestamps and logical counts +//! - GROUP BY / JOIN / ORDER BY LIMIT +//! - `changes_since` forward drain and multi-segment `last_rows` rewind +//! +//! Scale defaults keep CI fast; raise with `KOLDSTORE_CMS_LOAD_POSTS` / +//! `KOLDSTORE_CMS_LOAD_CONCURRENT`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::task::JoinHandle; +use tokio::time::sleep; + +use crate::common; +use crate::flush::harness::connect_peer; + +fn load_posts() -> i64 { + std::env::var("KOLDSTORE_CMS_LOAD_POSTS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1_200) +} + +fn load_users() -> i64 { + std::env::var("KOLDSTORE_CMS_LOAD_USERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(80) +} + +fn load_concurrent() -> i64 { + std::env::var("KOLDSTORE_CMS_LOAD_CONCURRENT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30) +} + +fn comments_per_post() -> i64 { + 2 +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wordpress_cms_high_load_flush_joins_and_changes_since() -> Result<()> { + common::require_pgrx_server().await?; + + let posts = load_posts(); + let users = load_users(); + let concurrent = load_concurrent(); + let comments_pp = comments_per_post(); + + for target in common::scenario_pg_matrix() { + let db = common::TestDb::start(target, "cms_load").await?; + let schema = db.schema.clone(); + create_load_schema(&db, &schema).await?; + + for (table, order_by) in [ + ("wp_users", "id"), + ("wp_posts", "id"), + ("wp_postmeta", "meta_id"), + ("wp_comments", "comment_id"), + ("wp_terms", "term_id"), + ("wp_term_taxonomy", "term_taxonomy_id"), + ("wp_term_relationships", "object_id"), + ] { + manage_small_segments(&db, &format!("{schema}.{table}"), order_by).await?; + } + common::wait_for_async_worker(&db.client).await?; + + seed_bulk(&db, &schema, users, posts, comments_pp).await?; + common::fence_async_mirror(&db.client).await?; + sleep(Duration::from_millis(200)).await; + + let posts_rel = format!("{schema}.wp_posts"); + let comments_rel = format!("{schema}.wp_comments"); + assert_eq!(common::row_count(&db.client, &posts_rel).await?, posts); + assert_eq!( + common::row_count(&db.client, &comments_rel).await?, + posts * comments_pp + ); + + let want_ts: String = db + .client + .query_one( + "SELECT to_char( + (timestamptz '2024-01-01 08:00:00+00' + + ((42 % 200) || ' days')::interval + + ((42 % 24) || ' hours')::interval) + AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')", + &[], + ) + .await? + .get(0); + let got_ts: String = db + .client + .query_one( + &format!( + "SELECT to_char(post_date AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') + FROM {schema}.wp_posts WHERE id = 42" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!( + got_ts == want_ts, + "seed timestamp drift: {got_ts} != {want_ts}" + ); + + // Concurrent editorial traffic. + let stop = Arc::new(AtomicBool::new(false)); + let mut workers: Vec>> = Vec::new(); + { + let peer = connect_peer(&db).await?; + let schema = schema.clone(); + let stop = Arc::clone(&stop); + workers.push(tokio::spawn(async move { + let mut i = 0_i64; + while !stop.load(Ordering::Relaxed) { + i += 1; + if i > concurrent { + break; + } + peer.execute( + &format!( + "INSERT INTO {schema}.wp_posts ( + id, post_author, post_date, post_date_gmt, post_content, post_title, + post_excerpt, post_status, post_name, post_modified, post_modified_gmt, + post_type, comment_count + ) VALUES ( + $1, 1, now(), now(), 'live', $2, '', 'publish', $3, now(), now(), 'post', 0 + ) + ON CONFLICT (id) DO UPDATE SET post_title = EXCLUDED.post_title" + ), + &[ + &(posts + i), + &format!("Live post {i}"), + &format!("live-post-{i}"), + ], + ) + .await?; + sleep(Duration::from_millis(5)).await; + } + Ok(()) + })); + } + { + let peer = connect_peer(&db).await?; + let schema = schema.clone(); + let stop = Arc::clone(&stop); + workers.push(tokio::spawn(async move { + let mut i = 0_i64; + while !stop.load(Ordering::Relaxed) { + i += 1; + if i > concurrent { + break; + } + let target_id = 1 + ((i * 17) % posts); + peer.execute( + &format!( + "UPDATE {schema}.wp_posts + SET comment_count = comment_count + 1, post_modified = now() + WHERE id = $1" + ), + &[&target_id], + ) + .await + .ok(); + peer.execute( + &format!( + "INSERT INTO {schema}.wp_comments ( + comment_id, comment_post_id, comment_author, comment_author_email, + comment_date, comment_date_gmt, comment_content, user_id, comment_approved + ) VALUES ($1, $2, 'Live', 'live@example.com', now(), now(), $3, 0, '1') + ON CONFLICT DO NOTHING" + ), + &[ + &(posts * comments_pp + 100_000 + i), + &target_id, + &format!("live comment {i}"), + ], + ) + .await?; + sleep(Duration::from_millis(5)).await; + } + Ok(()) + })); + } + { + let peer = connect_peer(&db).await?; + let schema = schema.clone(); + let stop = Arc::clone(&stop); + workers.push(tokio::spawn(async move { + let mut i = 0_i64; + while !stop.load(Ordering::Relaxed) { + i += 1; + if i > concurrent { + break; + } + let _ = peer + .query_one( + &format!( + "SELECT count(*) FROM {schema}.wp_posts WHERE post_status = 'publish'" + ), + &[], + ) + .await?; + let _ = peer + .query( + &format!( + "SELECT u.display_name, count(*)::bigint + FROM {schema}.wp_posts p + JOIN {schema}.wp_users u ON u.id = p.post_author + WHERE p.post_status = 'publish' AND p.post_type = 'post' + GROUP BY u.display_name + ORDER BY count(*) DESC + LIMIT 10" + ), + &[], + ) + .await?; + sleep(Duration::from_millis(8)).await; + } + Ok(()) + })); + } + + for handle in workers { + handle.await??; + } + stop.store(true, Ordering::Relaxed); + common::fence_async_mirror(&db.client).await?; + + let publish_seed = (1..=posts).filter(|g| g % 17 != 0).count() as i64; + let published: i64 = db + .client + .query_one( + &format!("SELECT count(*) FROM {schema}.wp_posts WHERE post_status = 'publish'"), + &[], + ) + .await? + .get(0); + anyhow::ensure!( + published >= publish_seed + concurrent, + "publish count {published} < seed+live {}", + publish_seed + concurrent + ); + + // Flush under a light writer. + let during = { + let peer = connect_peer(&db).await?; + let schema = schema.clone(); + tokio::spawn(async move { + for i in 1..=15_i64 { + peer.execute( + &format!( + "INSERT INTO {schema}.wp_posts ( + id, post_author, post_date, post_date_gmt, post_content, post_title, + post_excerpt, post_status, post_name, post_modified, post_modified_gmt, + post_type, comment_count + ) VALUES ( + $1, 1, now(), now(), 'during', $2, '', 'publish', $3, now(), now(), 'post', 0 + ) ON CONFLICT DO NOTHING" + ), + &[ + &(posts + 1_000 + i), + &format!("Flush-time {i}"), + &format!("flush-time-{i}"), + ], + ) + .await + .ok(); + sleep(Duration::from_millis(20)).await; + } + Ok::<(), anyhow::Error>(()) + }) + }; + + for table in ["wp_posts", "wp_comments", "wp_postmeta", "wp_users"] { + let relation = format!("{schema}.{table}"); + let flushed = db.flush_table_with_force(&relation, true).await?; + anyhow::ensure!(flushed > 0, "{relation} flushed {flushed}"); + } + let _ = during.await?; + common::fence_async_mirror(&db.client).await?; + + let status = common::table_status(&db.client, &posts_rel).await?; + anyhow::ensure!( + status.cold_row_count >= posts, + "expected cold archive after flush: {:?}", + status + ); + anyhow::ensure!( + status.cold_segment_count >= 2, + "expected multi-segment cold archive: {:?}", + status + ); + + let after_ts: String = db + .client + .query_one( + &format!( + "SELECT to_char(post_date AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') + FROM {schema}.wp_posts WHERE id = 42" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!(after_ts == want_ts, "timestamp after flush: {after_ts}"); + + let top5: i64 = db + .client + .query_one( + &format!( + "SELECT count(*) FROM ( + SELECT p.id, count(c.comment_id) AS n + FROM {schema}.wp_posts p + LEFT JOIN {schema}.wp_comments c + ON c.comment_post_id = p.id AND c.comment_approved = '1' + WHERE p.post_status = 'publish' + GROUP BY p.id + ORDER BY n DESC, p.id + LIMIT 5 + ) q" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!(top5 == 5); + + let recent: i64 = db + .client + .query_one( + &format!( + "SELECT count(*) FROM ( + SELECT id FROM {schema}.wp_posts + WHERE post_status = 'publish' + ORDER BY post_date DESC, id DESC + LIMIT 25 + ) q" + ), + &[], + ) + .await? + .get(0); + anyhow::ensure!(recent == 25); + + // Forward exclusive cursor drain (bounded). + let mut cursor = 0_i64; + let mut drained = 0_i64; + for _ in 0..40 { + let page = db + .client + .query( + "SELECT seq FROM koldstore.changes_since($1::text::regclass, $2::bigint, 200) + ORDER BY seq", + &[&posts_rel, &cursor], + ) + .await?; + if page.is_empty() { + break; + } + for row in &page { + let seq: i64 = row.get(0); + anyhow::ensure!(seq > cursor, "cursor must advance"); + cursor = seq; + drained += 1; + } + } + anyhow::ensure!( + drained >= 200, + "forward changes_since drain too small: {drained}" + ); + + // Multi-segment last_rows rewind (regression for tip-segment-only bug). + let last_n = 80_i32; + let last = db + .client + .query( + "SELECT (pk->>'id')::bigint, source, seq + FROM koldstore.changes_since($1::text::regclass, 0, 1000, $2::integer) + ORDER BY seq", + &[&posts_rel, &last_n], + ) + .await?; + anyhow::ensure!( + last.len() == last_n as usize, + "last_rows={last_n} got {} (must span multiple cold segments under load)", + last.len() + ); + anyhow::ensure!( + last.windows(2) + .all(|w| w[0].get::<_, i64>(2) < w[1].get::<_, i64>(2)), + "last_rows must deliver strictly increasing seq" + ); + + let healthy: bool = db + .client + .query_one( + "SELECT (koldstore.async_mirror_status()->>'healthy')::boolean", + &[], + ) + .await? + .get(0); + anyhow::ensure!(healthy, "async mirror unhealthy after CMS load"); + } + + Ok(()) +} + +async fn create_load_schema(db: &common::TestDb, schema: &str) -> Result<()> { + db.client + .batch_execute(&format!( + r#" + CREATE TABLE {schema}.wp_users ( + id bigint PRIMARY KEY, + user_login text NOT NULL, + user_email text NOT NULL, + display_name text NOT NULL, + user_registered timestamptz NOT NULL, + user_status int NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_posts ( + id bigint PRIMARY KEY, + post_author bigint NOT NULL, + post_date timestamptz NOT NULL, + post_date_gmt timestamptz NOT NULL, + post_content text NOT NULL, + post_title text NOT NULL, + post_excerpt text NOT NULL DEFAULT '', + post_status text NOT NULL, + post_name text NOT NULL, + post_modified timestamptz NOT NULL, + post_modified_gmt timestamptz NOT NULL, + post_type text NOT NULL, + comment_count bigint NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_postmeta ( + meta_id bigint PRIMARY KEY, + post_id bigint NOT NULL, + meta_key text NOT NULL, + meta_value text NOT NULL + ); + CREATE TABLE {schema}.wp_comments ( + comment_id bigint PRIMARY KEY, + comment_post_id bigint NOT NULL, + comment_author text NOT NULL, + comment_author_email text NOT NULL, + comment_date timestamptz NOT NULL, + comment_date_gmt timestamptz NOT NULL, + comment_content text NOT NULL, + user_id bigint NOT NULL DEFAULT 0, + comment_approved text NOT NULL DEFAULT '1' + ); + CREATE TABLE {schema}.wp_terms ( + term_id bigint PRIMARY KEY, + name text NOT NULL, + slug text NOT NULL + ); + CREATE TABLE {schema}.wp_term_taxonomy ( + term_taxonomy_id bigint PRIMARY KEY, + term_id bigint NOT NULL, + taxonomy text NOT NULL, + description text NOT NULL DEFAULT '', + parent bigint NOT NULL DEFAULT 0, + count bigint NOT NULL DEFAULT 0 + ); + CREATE TABLE {schema}.wp_term_relationships ( + object_id bigint NOT NULL, + term_taxonomy_id bigint NOT NULL, + term_order int NOT NULL DEFAULT 0, + PRIMARY KEY (object_id, term_taxonomy_id) + ); + CREATE INDEX {schema}_posts_status_date_idx ON {schema}.wp_posts (post_status, post_date); + CREATE INDEX {schema}_comments_post_idx ON {schema}.wp_comments (comment_post_id); + "# + )) + .await + .context("create CMS load schema")?; + Ok(()) +} + +async fn manage_small_segments(db: &common::TestDb, relation: &str, order_by: &str) -> Result<()> { + db.client + .batch_execute("SET koldstore.min_max_rows_per_file = 1;") + .await?; + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => 400, + min_flush_rows => 1, + max_rows_per_file => 100, + auto_flush => false, + migration_order_by => $3 + ) + "#, + &[&relation, &db.storage_name, &order_by], + ) + .await + .with_context(|| format!("manage {relation}"))?; + common::assert_catalog_has_active_schema(&db.client, relation).await?; + Ok(()) +} + +async fn seed_bulk( + db: &common::TestDb, + schema: &str, + users: i64, + posts: i64, + comments_pp: i64, +) -> Result<()> { + db.client + .batch_execute(&format!( + r#" + INSERT INTO {schema}.wp_users (id, user_login, user_email, display_name, user_registered, user_status) + SELECT g, 'user'||g, 'user'||g||'@example.com', 'Author '||g, + timestamptz '2023-01-01 00:00:00+00' + ((g % 400) || ' days')::interval, 0 + FROM generate_series(1, {users}) g; + + INSERT INTO {schema}.wp_terms (term_id, name, slug) + SELECT g, 'Term '||g, 'term-'||g FROM generate_series(1, 20) g; + INSERT INTO {schema}.wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) + SELECT g, g, CASE WHEN g % 2 = 0 THEN 'category' ELSE 'post_tag' END, '', 0, 0 + FROM generate_series(1, 20) g; + + INSERT INTO {schema}.wp_posts ( + id, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, + post_status, post_name, post_modified, post_modified_gmt, post_type, comment_count + ) + SELECT g, + 1 + ((g - 1) % {users}), + timestamptz '2024-01-01 08:00:00+00' + ((g % 200) || ' days')::interval + + ((g % 24) || ' hours')::interval, + timestamptz '2024-01-01 08:00:00+00' + ((g % 200) || ' days')::interval + + ((g % 24) || ' hours')::interval, + 'Body '||g, 'Post title '||g, 'Excerpt '||g, + CASE WHEN g % 17 = 0 THEN 'draft' ELSE 'publish' END, + 'post-'||g, + timestamptz '2024-01-01 09:00:00+00' + ((g % 200) || ' days')::interval, + timestamptz '2024-01-01 09:00:00+00' + ((g % 200) || ' days')::interval, + CASE WHEN g % 40 = 0 THEN 'page' ELSE 'post' END, + {comments_pp} + FROM generate_series(1, {posts}) g; + + INSERT INTO {schema}.wp_term_relationships (object_id, term_taxonomy_id, term_order) + SELECT g, 2 * (1 + ((g - 1) % 10)), 0 FROM generate_series(1, {posts}) g + ON CONFLICT DO NOTHING; + + INSERT INTO {schema}.wp_postmeta (meta_id, post_id, meta_key, meta_value) + SELECT (p.id - 1) * 2 + m, p.id, + CASE m WHEN 1 THEN 'views' ELSE 'featured' END, + (p.id % 1000)::text + FROM {schema}.wp_posts p + CROSS JOIN generate_series(1, 2) m; + + INSERT INTO {schema}.wp_comments ( + comment_id, comment_post_id, comment_author, comment_author_email, + comment_date, comment_date_gmt, comment_content, user_id, comment_approved + ) + SELECT (p.id - 1) * {comments_pp} + c, p.id, 'Commenter '||c, 'c'||c||'@example.com', + p.post_date + ((c || ' hours')::interval), + p.post_date_gmt + ((c || ' hours')::interval), + 'Comment '||c||' on '||p.id, 0, '1' + FROM {schema}.wp_posts p + CROSS JOIN generate_series(1, {comments_pp}) c; + "# + )) + .await + .context("seed CMS load")?; + Ok(()) +} diff --git a/tests/e2e/suite/extension_lifecycle.rs b/tests/e2e/suite/extension_lifecycle.rs index 540a35ed..81590385 100644 --- a/tests/e2e/suite/extension_lifecycle.rs +++ b/tests/e2e/suite/extension_lifecycle.rs @@ -45,8 +45,7 @@ async fn drop_extension_requires_cascade_with_managed_tables_then_reinstalls() - "expected dependent-object / CASCADE error, got: {message}" ); - db.client - .batch_execute("DROP EXTENSION koldstore CASCADE;") + drop_extension_cascade(&db.client) .await .context("DROP EXTENSION CASCADE")?; @@ -64,6 +63,8 @@ async fn drop_extension_requires_cascade_with_managed_tables_then_reinstalls() - .batch_execute("CREATE EXTENSION koldstore;") .await .context("CREATE EXTENSION after CASCADE drop")?; + // Pause is shared-memory / preloaded-library state and survives DROP. + let _ = common::release_async_worker_stop_lock(&db.client).await; let version: String = db .client @@ -107,9 +108,14 @@ async fn quiesce_extension_drop_targets(client: &tokio_postgres::Client) -> Resu let _ = client .batch_execute("UPDATE koldstore.schemas SET active = false WHERE active;") .await; - let _ = common::terminate_async_worker(client).await; + // Pause supervisor dispatch so terminate sticks; a bare terminate lets the + // WAL applier respawn and deadlock DROP EXTENSION (AccessExclusiveLock vs + // applier AccessShareLock). + if common::force_stop_async_worker(client).await.is_err() { + let _ = common::terminate_async_worker(client).await; + } // Brief settle so terminated backends release relation locks before DROP. - tokio::time::sleep(Duration::from_millis(50)).await; + tokio::time::sleep(Duration::from_millis(100)).await; Ok(()) } @@ -144,6 +150,36 @@ async fn drop_extension_without_cascade_error(client: &tokio_postgres::Client) - bail!("DROP EXTENSION without CASCADE kept deadlocking: {last_message}") } +/// Drops the extension with CASCADE, retrying transient deadlocks after re-quiesce. +async fn drop_extension_cascade(client: &tokio_postgres::Client) -> Result<()> { + let mut last_message = String::new(); + for attempt in 1..=4 { + match client + .batch_execute("DROP EXTENSION koldstore CASCADE;") + .await + { + Ok(()) => return Ok(()), + Err(error) => { + let _ = client.batch_execute("ROLLBACK").await; + let message = error + .as_db_error() + .map(|db| db.message().to_string()) + .unwrap_or_else(|| format!("{error:?}")); + let lower = message.to_ascii_lowercase(); + if lower.contains("deadlock detected") || lower.contains("canceling statement") { + last_message = message; + // Extension may still be present; re-quiesce before retry. + let _ = quiesce_extension_drop_targets(client).await; + tokio::time::sleep(Duration::from_millis(50 * attempt as u64)).await; + continue; + } + return Err(error).context("DROP EXTENSION CASCADE"); + } + } + } + bail!("DROP EXTENSION CASCADE kept deadlocking: {last_message}") +} + #[tokio::test] async fn create_extension_is_idempotent_with_if_not_exists() -> Result<()> { common::require_pgrx_server().await?; diff --git a/tests/e2e/suite/first_time_user_journey.rs b/tests/e2e/suite/first_time_user_journey.rs new file mode 100644 index 00000000..d619448f --- /dev/null +++ b/tests/e2e/suite/first_time_user_journey.rs @@ -0,0 +1,319 @@ +//! First-time user journey: multi-table manage, second DB, unmanage sibling. +//! +//! Mimics someone trying KoldStore for the first time across one database with +//! two managed tables and (when the E2E DB pool has ≥2 workers) a second +//! database in parallel. Pieces of this exist elsewhere (`demigrate_matrix`, +//! `flush_multiple_tables_in_parallel_*`, `multi_database_stress`); this test +//! stitches the quickstart-shaped path into one regression. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result}; +use tokio::task::JoinHandle; + +use crate::common; + +const ORDERS_ROWS: i64 = 120; +const MESSAGES_ROWS: i64 = 80; +const INVOICES_ROWS: i64 = 60; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn first_time_user_multi_table_unmanage_and_optional_second_database() -> Result<()> { + common::require_pgrx_server().await?; + + for target in common::scenario_pg_matrix() { + let primary = common::TestDb::start(target.clone(), "ftu_primary").await?; + let orders = primary.relation("orders"); + let messages = primary.relation("messages"); + + primary + .client + .batch_execute(&format!( + "CREATE TABLE {orders} ( + id bigint PRIMARY KEY, + customer text NOT NULL, + amount_cents bigint NOT NULL + ); + CREATE TABLE {messages} ( + id bigint PRIMARY KEY, + account_id bigint NOT NULL, + body text NOT NULL + );" + )) + .await + .context("create primary app tables")?; + + // Separate statements so slot provisioning is not after uncommitted DDL. + manage_table(&primary, &orders, 50).await?; + manage_table(&primary, &messages, 50).await?; + common::wait_for_async_worker(&primary.client).await?; + + let mut second_db: Option<(common::TestDb, String)> = None; + if common::e2e_db_pool_enabled() && common::e2e_pool_size() >= 2 { + let secondary = common::TestDb::start(target.clone(), "ftu_secondary").await?; + let invoices = secondary.relation("invoices"); + secondary + .client + .batch_execute(&format!( + "CREATE TABLE {invoices} ( + id bigint PRIMARY KEY, + total_cents bigint NOT NULL, + note text NOT NULL + );" + )) + .await + .context("create secondary invoices table")?; + manage_table(&secondary, &invoices, 40).await?; + common::wait_for_async_worker(&secondary.client).await?; + second_db = Some((secondary, invoices)); + } + + // Parallel inserts across tables / databases (first-time multi-tenant feel). + let orders_insert = spawn_insert( + &primary, + format!( + "INSERT INTO {orders} + SELECT g, 'cust-' || g, g * 100 + FROM generate_series(1, {ORDERS_ROWS}) g" + ), + ); + let messages_insert = spawn_insert( + &primary, + format!( + "INSERT INTO {messages} + SELECT g, g % 7, 'msg-' || g + FROM generate_series(1, {MESSAGES_ROWS}) g" + ), + ); + let invoices_insert = if let Some((ref secondary, ref invoices)) = second_db { + Some(spawn_insert( + secondary, + format!( + "INSERT INTO {invoices} + SELECT g, g * 1000, 'inv-' || g + FROM generate_series(1, {INVOICES_ROWS}) g" + ), + )) + } else { + None + }; + + orders_insert.await??; + messages_insert.await??; + if let Some(handle) = invoices_insert { + handle.await??; + } + + common::fence_async_mirror(&primary.client).await?; + if let Some((ref secondary, _)) = second_db { + common::fence_async_mirror(&secondary.client).await?; + } + + anyhow::ensure!(common::row_count(&primary.client, &orders).await? == ORDERS_ROWS); + anyhow::ensure!(common::row_count(&primary.client, &messages).await? == MESSAGES_ROWS); + if let Some((ref secondary, ref invoices)) = second_db { + anyhow::ensure!(common::row_count(&secondary.client, invoices).await? == INVOICES_ROWS); + } + + let orders_feed = drain_changes_since(&primary.client, &orders, 0, 40).await?; + anyhow::ensure!( + orders_feed.len() as i64 == ORDERS_ROWS, + "orders changes_since expected {ORDERS_ROWS}, got {}", + orders_feed.len() + ); + assert_exclusive_seq(&orders_feed)?; + let messages_feed = drain_changes_since(&primary.client, &messages, 0, 40).await?; + anyhow::ensure!(messages_feed.len() as i64 == MESSAGES_ROWS); + assert_exclusive_seq(&messages_feed)?; + if let Some((ref secondary, ref invoices)) = second_db { + let invoices_feed = drain_changes_since(&secondary.client, invoices, 0, 40).await?; + anyhow::ensure!(invoices_feed.len() as i64 == INVOICES_ROWS); + assert_exclusive_seq(&invoices_feed)?; + } + + let flushed_orders = primary.flush_table_with_force(&orders, true).await?; + let flushed_messages = primary.flush_table_with_force(&messages, true).await?; + anyhow::ensure!(flushed_orders > 0 && flushed_messages > 0); + anyhow::ensure!(common::row_count(&primary.client, &orders).await? == ORDERS_ROWS); + anyhow::ensure!(common::row_count(&primary.client, &messages).await? == MESSAGES_ROWS); + if let Some((ref secondary, ref invoices)) = second_db { + let flushed = secondary.flush_table_with_force(invoices, true).await?; + anyhow::ensure!(flushed > 0); + anyhow::ensure!(common::row_count(&secondary.client, invoices).await? == INVOICES_ROWS); + } + + // Unmanage one sibling; the other must keep accepting DML + changes_since. + let deactivated: i64 = primary + .client + .query_one( + "SELECT koldstore.unmanage_table($1::text::regclass, true, true)", + &[&messages], + ) + .await? + .get(0); + anyhow::ensure!(deactivated == 1); + let messages_active: i64 = primary + .client + .query_one( + "SELECT count(*) FROM koldstore.schemas \ + WHERE table_oid = $1::text::regclass::oid AND active", + &[&messages], + ) + .await? + .get(0); + let orders_active: i64 = primary + .client + .query_one( + "SELECT count(*) FROM koldstore.schemas \ + WHERE table_oid = $1::text::regclass::oid AND active", + &[&orders], + ) + .await? + .get(0); + anyhow::ensure!(messages_active == 0 && orders_active == 1); + anyhow::ensure!(common::row_count(&primary.client, &messages).await? == MESSAGES_ROWS); + + let cursor_before = tip_seq(&primary.client, &orders).await?; + primary + .client + .execute( + &format!("INSERT INTO {orders} VALUES ($1, $2, $3)"), + &[&(ORDERS_ROWS + 1), &"after-unmanage", &1_i64], + ) + .await?; + common::fence_async_mirror(&primary.client).await?; + let after = drain_changes_since(&primary.client, &orders, cursor_before, 20).await?; + let ids: BTreeSet = after.iter().map(|(_, id, _)| *id).collect(); + anyhow::ensure!( + ids.contains(&(ORDERS_ROWS + 1)), + "orders changes_since must show post-unmanage insert; got {ids:?}" + ); + anyhow::ensure!(common::row_count(&primary.client, &orders).await? == ORDERS_ROWS + 1); + + if let Some((ref secondary, ref invoices)) = second_db { + let cursor = tip_seq(&secondary.client, invoices).await?; + secondary + .client + .execute( + &format!("INSERT INTO {invoices} VALUES ($1, $2, $3)"), + &[&(INVOICES_ROWS + 1), &1_i64, &"still-ok"], + ) + .await?; + common::fence_async_mirror(&secondary.client).await?; + let page = drain_changes_since(&secondary.client, invoices, cursor, 20).await?; + let ids: BTreeSet = page.iter().map(|(_, id, _)| *id).collect(); + anyhow::ensure!(ids.contains(&(INVOICES_ROWS + 1))); + anyhow::ensure!( + common::row_count(&secondary.client, invoices).await? == INVOICES_ROWS + 1 + ); + let healthy: bool = secondary + .client + .query_one( + "SELECT (koldstore.async_mirror_status()->>'healthy')::boolean", + &[], + ) + .await? + .get(0); + anyhow::ensure!(healthy, "secondary database async mirror must stay healthy"); + } + } + + Ok(()) +} + +async fn manage_table(db: &common::TestDb, relation: &str, hot_row_limit: i64) -> Result<()> { + db.client + .execute( + r#" + SELECT koldstore.manage_table( + table_name => $1::text::regclass, + storage => $2, + hot_row_limit => $3::bigint, + min_flush_rows => 1, + max_rows_per_file => 1000, + auto_flush => false + ) + "#, + &[&relation, &db.storage_name, &hot_row_limit], + ) + .await + .with_context(|| format!("manage_table {relation}"))?; + common::assert_catalog_has_active_schema(&db.client, relation).await?; + Ok(()) +} + +fn spawn_insert(db: &common::TestDb, sql: String) -> JoinHandle> { + let conninfo = db.target.connection_string(); + tokio::spawn(async move { + let (client, connection) = tokio_postgres::connect(&conninfo, tokio_postgres::NoTls) + .await + .context("peer connect for insert")?; + tokio::spawn(async move { + let _ = connection.await; + }); + client + .batch_execute(&sql) + .await + .context("parallel insert")?; + Ok(()) + }) +} + +async fn tip_seq(client: &tokio_postgres::Client, relation: &str) -> Result { + let row = client + .query_one( + "SELECT COALESCE(max(seq), 0)::bigint \ + FROM koldstore.changes_since($1::text::regclass, 0, 1, 1)", + &[&relation], + ) + .await?; + Ok(row.get(0)) +} + +async fn drain_changes_since( + client: &tokio_postgres::Client, + relation: &str, + since_seq: i64, + limit: i32, +) -> Result> { + let mut cursor = since_seq; + let mut out = Vec::new(); + loop { + let rows = client + .query( + "SELECT seq, (pk->>'id')::bigint AS id, source \ + FROM koldstore.changes_since($1::text::regclass, $2, $3) \ + ORDER BY seq", + &[&relation, &cursor, &limit], + ) + .await + .with_context(|| format!("changes_since page for {relation} since={cursor}"))?; + if rows.is_empty() { + break; + } + for row in rows { + let seq: i64 = row.get(0); + anyhow::ensure!( + seq > cursor, + "exclusive cursor must advance: got {seq} after {cursor}" + ); + cursor = seq; + out.push((seq, row.get(1), row.get(2))); + } + } + Ok(out) +} + +fn assert_exclusive_seq(rows: &[(i64, i64, String)]) -> Result<()> { + let mut last = 0_i64; + let mut seen_ids = BTreeSet::new(); + for (seq, id, _) in rows { + anyhow::ensure!(*seq > last, "seq must be strictly increasing"); + anyhow::ensure!( + seen_ids.insert(*id), + "duplicate id {id} in changes_since drain" + ); + last = *seq; + } + Ok(()) +} diff --git a/tests/e2e/suite/full_lifecycle.rs b/tests/e2e/suite/full_lifecycle.rs index 570fe51b..db52ce08 100644 --- a/tests/e2e/suite/full_lifecycle.rs +++ b/tests/e2e/suite/full_lifecycle.rs @@ -871,7 +871,7 @@ fn relation(pg_version: u16) -> String { } fn mirror_relation(pg_version: u16) -> String { - format!("koldstore.full_lifecycle_wide_pg{pg_version}__cl") + common::change_log_mirror_relation(&relation(pg_version)) } fn storage_name(pg_version: u16) -> String { diff --git a/tests/e2e/suite/jobs_and_recovery.rs b/tests/e2e/suite/jobs_and_recovery.rs index c282b8a2..ec9bedbc 100644 --- a/tests/e2e/suite/jobs_and_recovery.rs +++ b/tests/e2e/suite/jobs_and_recovery.rs @@ -206,7 +206,7 @@ async fn migrate_and_flush_sql_return_job_ids_and_expose_progress_on_pgrx() -> R assert!(["pending", "running", "completed"] .contains(&migrate_job.get::<_, String>("status").as_str())); - let mirror_relation = format!("koldstore.{}__cl", table.table_name); + let mirror_relation = common::change_log_mirror_relation(&table.relation); wait_for_completed_job(&db.client, &migrate_job_id).await?; let base_rows = common::row_count(&db.client, &table.relation).await?; let mirror_rows = common::row_count(&db.client, &mirror_relation).await?; diff --git a/tests/e2e/suite/mod.rs b/tests/e2e/suite/mod.rs index b2961c11..2fc78f84 100644 --- a/tests/e2e/suite/mod.rs +++ b/tests/e2e/suite/mod.rs @@ -1,9 +1,12 @@ //! Cross-cutting lifecycle / contract E2E category. mod async_load_soak; +mod cms_wordpress_journey; +mod cms_wordpress_load; mod endurance; mod extension_lifecycle; mod failure_injection; +mod first_time_user_journey; mod flush_memory_spike; mod full_lifecycle; mod jobs_and_recovery; diff --git a/tests/examples/Cargo.toml b/tests/examples/Cargo.toml index cbf840d4..82d18c34 100644 --- a/tests/examples/Cargo.toml +++ b/tests/examples/Cargo.toml @@ -13,6 +13,7 @@ ignored = [ "koldstore-memory-tests", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", "serde", "serde_json", ] @@ -45,6 +46,7 @@ koldstore-common.workspace = true koldstore-memory-tests = { path = "../memory" } koldstore-storage = { workspace = true, features = ["s3"] } koldstore-supervisor.workspace = true +koldstore-wal-mirror.workspace = true parquet.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/tests/memory/heap_profile.md b/tests/memory/heap_profile.md index 73a0cde3..40d19e76 100644 --- a/tests/memory/heap_profile.md +++ b/tests/memory/heap_profile.md @@ -33,6 +33,13 @@ It fails when absolute or per-cycle retained growth exceeds | `KOLDSTORE_MEMORY_MAX_FLUSH_CONTEXT_SPIKE_BYTES` | peak context above baseline | | `KOLDSTORE_MEMORY_MAX_FLUSH_RSS_RETAINED_BYTES` | retained RSS after cool-down | | `KOLDSTORE_MEMORY_MAX_FLUSH_CONTEXT_RETAINED_BYTES` | retained context after cool-down | +| `KOLDSTORE_WAL_STARTUP_MS` | WAL applier restart SLO (default 5000; includes 1s supervisor grace) | +| `KOLDSTORE_WAL_IDLE_RSS_MAX_BYTES` | idle WAL applier RSS cap (default 256 MiB, includes mapped `shared_buffers`) | +| `KOLDSTORE_WAL_IDLE_RSS_SLACK_BYTES` | idle WAL RSS may exceed a sibling client backend by this much (default 64 MiB) | +| `KOLDSTORE_FLUSH_STARTUP_MS` | queue flush executor appear SLO (default 5000) | +| `KOLDSTORE_FLUSH_EXECUTOR_RSS_MAX_BYTES` | one flush executor RSS cap at default `max_rows_per_file` (default 256 MiB) | +| `KOLDSTORE_FLUSH_CONCURRENT_SELECT_MAX_MS` | max PK/`SELECT 1` latency on another session during flush (default 2000) | +| `KOLDSTORE_FLUSH_CONCURRENT_INSERT_MAX_MS` | max small INSERT latency on another session during flush (default 2000) | | `KOLDSTORE_MEMORY_LARGE_QUERY_ROWS` | rows for large merge-scan memory gate (default 20000) | | `KOLDSTORE_MINIO=1` | enable MinIO flush + parquet GET path | | `KOLDSTORE_MEMORY_SKIP_E2E=1` | unit probes only | @@ -40,6 +47,13 @@ It fails when absolute or per-cycle retained growth exceeds Peak-during-operation gates live in `suite::flush_memory_spike` and poll cluster RSS while flush / large SELECTs run (not only post-cycle retained growth). +Per-process launcher gates: + +- `dml::wal_applier_footprint` — idle WAL applier RSS vs a sibling client + backend, no idle RSS growth, restart within the startup SLO +- `flush::flush_executor_footprint` — queue executor startup + process RSS, and + concurrent hot PK `SELECT` / `INSERT` latency while encode runs + ## Plain Postgres vs koldstore comparison table `suite::memory_leak::memory_overhead_vs_plain_postgres_reports_spikes_and_deltas` diff --git a/tests/memory/run_memory_checks.sh b/tests/memory/run_memory_checks.sh index 0223c84e..3b422382 100755 --- a/tests/memory/run_memory_checks.sh +++ b/tests/memory/run_memory_checks.sh @@ -32,10 +32,10 @@ if [[ -x "${ROOT_DIR}/scripts/run-pg-e2e.sh" ]]; then source "${E2E_ENV_FILE}" fi -echo "running deep E2E memory leak + peak-spike gates (flush/DML/merge-scan; MinIO when enabled)" +echo "running deep E2E memory leak + peak-spike + worker-footprint gates" echo "comparison metrics table is printed at the end of memory_overhead_vs_plain_postgres_*" # Always show stdout/stderr so the plain-vs-koldstore memory table is visible. -cargo nextest run -p e2e -E 'test(memory_leak::) + test(flush_memory_spike::)' --test-threads 1 --no-capture +cargo nextest run -p e2e -E 'test(memory_leak::) + test(flush_memory_spike::) + test(wal_applier_footprint::) + test(flush_executor_footprint::)' --test-threads 1 --no-capture if command -v valgrind >/dev/null 2>&1; then echo "valgrind is available; optional secondary pass: cargo pgrx test --valgrind" diff --git a/tests/sql/setup.sql b/tests/sql/setup.sql index c7f5013a..4d7c6e55 100644 --- a/tests/sql/setup.sql +++ b/tests/sql/setup.sql @@ -6,6 +6,8 @@ SET client_min_messages TO WARNING; DROP SCHEMA IF EXISTS sqlreg CASCADE; +-- DROP SCHEMA CASCADE runs KoldStore managed-table cleanup (catalog deactivate, +-- cold GC, mirror drop) before heaps disappear; do not leave active orphans. CREATE SCHEMA sqlreg; -- Catalog storage rows survive DROP SCHEMA; only register when missing so diff --git a/tests/storage/Cargo.toml b/tests/storage/Cargo.toml index 94147a1c..fc2b410f 100644 --- a/tests/storage/Cargo.toml +++ b/tests/storage/Cargo.toml @@ -7,7 +7,12 @@ repository.workspace = true [package.metadata.cargo-machete] # These are referenced by the shared e2e module included through `#[path]`. -ignored = ["koldstore-common", "koldstore-storage", "koldstore-supervisor"] +ignored = [ + "koldstore-common", + "koldstore-storage", + "koldstore-supervisor", + "koldstore-wal-mirror", +] [[test]] name = "pg_vs_koldstore" @@ -21,6 +26,7 @@ koldstore-memory-tests = { path = "../memory" } # Shared via `#[path = "../e2e/common/mod.rs"]`. koldstore-storage = { workspace = true, features = ["s3"] } koldstore-supervisor.workspace = true +koldstore-wal-mirror.workspace = true serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["macros"] } diff --git a/tests/storage/README.md b/tests/storage/README.md index cdaae21f..757df493 100644 --- a/tests/storage/README.md +++ b/tests/storage/README.md @@ -30,7 +30,8 @@ Order of measurement (isolated `--side` / `--all-sides`): 4. **Hot-only PK lookups before flush** (full heap still present) 5. Flush older managed rows to zstd Parquet (duration, peak cluster RSS, rows/s, cold-Parquet MiB/s) — skipped for PostgreSQL-only -6. **Cold-only** PK lookups (`id = 1`) + **hot+cold 50/50 mix** +6. **Hot-only PK lookups after flush** (newest id still hot; must not open + Parquet) + **cold-only** PK lookups (`id = 1`) + **hot+cold 50/50 mix** 7. **`changes_since` full drain** — page exclusive `seq` cursor from 0 in batches of 500 (override `KOLDSTORE_STORAGE_CHANGES_SINCE_BATCH`) until the feed is empty; report duration + rows/s for the full latest-state set @@ -44,9 +45,13 @@ shared-buffer warm-up from a prior side, and leftover WAL/clog skewing insert timing. Each side’s JSON records `generated_at` and `git_commit`. Both source tables have autovacuum disabled by the schema, and the harness -applies the same benchmark-only setting to the generated mirror. A long async -catch-up therefore cannot launch maintenance during a following timed phase. -The harness runs the documented explicit maintenance phase instead. +applies the same benchmark-only setting to the generated mirror. After manage / +warm-up, timed async phases **pause supervisor WAL dispatch and terminate the +persistent WAL applier** (`force_stop`); GUC-off alone is not enough under the +sticky supervisor. Catch-up is measured separately via foreground +`wait_for_async_mirror()` fences. A long background apply therefore cannot run +during a following timed DML/query phase. The harness runs the documented +explicit maintenance phase instead. The harness prints a **Main comparison** headline table plus a **Detail** section. Columns are **PostgreSQL only** and **PG + KoldStore**. Flush rows diff --git a/tests/storage/pg_vs_koldstore.rs b/tests/storage/pg_vs_koldstore.rs index f21cdd20..de105917 100644 --- a/tests/storage/pg_vs_koldstore.rs +++ b/tests/storage/pg_vs_koldstore.rs @@ -5,11 +5,12 @@ //! fair merge-scan overhead). On PostgreSQL-only, **cold-id / hot+cold PK //! lookups also run before `VACUUM FULL`** so they are not compared against a //! freshly rewritten 10M heap. Managed sides flush older rows to zstd Parquet -//! (timing + peak RSS), time **cold-only** and **hot+cold (50/50)** after flush -//! (before hot-heap VACUUM), drain **`changes_since`** over the full latest-state -//! set (paged cursor), then VACUUM for the maintenance metric and compare -//! PostgreSQL heap/index sizes versus total hot+cold footprint. Flush report -//! rows include write throughput (rows/s) and cold-Parquet bandwidth (MiB/s). +//! (timing + peak RSS), time **still-hot PK**, **cold-only**, and **hot+cold +//! (50/50)** after flush (before hot-heap VACUUM), drain **`changes_since`** +//! over the full latest-state set (paged cursor), then VACUUM for the +//! maintenance metric and compare PostgreSQL heap/index sizes versus total +//! hot+cold footprint. Flush report rows include write throughput (rows/s) +//! and cold-Parquet bandwidth (MiB/s). //! //! Timed INSERT always seeds into an empty table growing to `rows` on every //! side — `hot_row_limit` does **not** make managed INSERT faster (flush has @@ -21,8 +22,11 @@ //! Expect foreground insert ≈ identical or managed slightly slower once both //! sides start the timed seed from the same recycled WAL baseline and retain //! WAL the same way during seed (pg-only uses a temporary logical pin). -//! Mirror apply remains a separate catch-up row on managed. -//! Managed sizes always include `koldstore.
__cl` heap + indexes. +//! Mirror apply remains a separate catch-up row on managed; during timed +//! phases the sticky WAL applier is paused+terminated so query/DML samples +//! are not contaminated by a respawned background apply process. +//! Managed sizes always include the schema-qualified change-log mirror heap + +//! indexes (`koldstore._
__cl`, hash-bounded when needed). //! //! Published runs isolate each column via `KOLDSTORE_STORAGE_SIDE=pg|async` //! on a wiped + re-initdb pgrx data directory (see @@ -149,6 +153,9 @@ struct SideMetrics { update: Timing, delete: Timing, query_hot_only: Timing, + /// After flush: repeated PK lookup of a still-hot id (newest / retained hot). + /// PostgreSQL-only times the same SQL on the full pre-VACUUM heap (no flush). + query_hot_after_flush: Timing, /// After flush: alternating hot PK + cold PK lookups (50/50). query_hot_cold: Timing, /// After flush: cold PK only (`id = 1`). @@ -344,8 +351,8 @@ async fn pg_vs_koldstore_storage_and_speed_comparison() -> Result<()> { result } StorageSide::Combined => { - // Mirror tables are `koldstore.__cl`, so table names - // must be unique across schemas in the shared E2E database. + // Mirror names encode schema + relation; keep table names unique in + // the shared E2E database for readable artifacts. let baseline_table = format!("{}_baseline", db.schema); let managed_table = format!("{}_managed", db.schema); let baseline = format!("{}.{}", db.schema, baseline_table); @@ -483,6 +490,13 @@ async fn run_pg_only_body( let _step = common::log_step_always("storage_cmp: time pg-only cold-id PK lookups"); time_point_queries(&db.client, baseline, cold_id).await? }; + // Same SQL as managed post-flush hot PK; no flush on this side so the full + // heap is still present (measured before VACUUM FULL like cold-id / mix). + let query_hot_after_flush = { + let _step = + common::log_step_always("storage_cmp: time pg-only hot PK (post-flush baseline)"); + time_point_queries(&db.client, baseline, hot_id).await? + }; let query_hot_cold = { let _step = common::log_step_always("storage_cmp: time pg-only mixed hot+cold PK lookups"); time_mixed_hot_cold_queries(&db.client, baseline, hot_id, cold_id).await? @@ -505,6 +519,7 @@ async fn run_pg_only_body( update, delete, query_hot_only, + query_hot_after_flush, query_hot_cold, query_cold_only, vacuum, @@ -533,7 +548,7 @@ async fn run_pg_only_body( async fn run_managed_only_body( db: &common::TestDb, managed: &str, - managed_table: &str, + _managed_table: &str, rows: i64, hot_limit: i64, dml_sample: i64, @@ -542,9 +557,10 @@ async fn run_managed_only_body( warmup_rows: i64, ) -> Result<()> { assert_async_worker_disabled_for_benchmark(&db.client).await?; + let managed_mirror = common::change_log_mirror_relation(managed); db.client .batch_execute(&format!( - "ALTER TABLE koldstore.{managed_table}__cl SET (autovacuum_enabled = false)" + "ALTER TABLE {managed_mirror} SET (autovacuum_enabled = false)" )) .await .context("disable autovacuum on benchmark mirror")?; @@ -609,13 +625,13 @@ async fn run_managed_only_body( common::assert_managed_read_plan(&plan_pre_flush)?; anyhow::ensure!( !plan_pre_flush.contains("Custom Scan (KoldMergeScan)") - || plan_pre_flush.contains("Parquet Segments Opened: 0") - || plan_pre_flush.contains("Parquet Segments Planned: 0"), - "pre-flush PK lookup must not open Parquet (no cold yet), got:\n{plan_pre_flush}" + && plan_pre_flush.contains("Index Scan"), + "pre-flush hot PK must use native Index Scan (empty cold → no KoldMergeScan), got:\n{plan_pre_flush}" ); } let query_hot_only = { let _step = common::log_step_always("storage_cmp: time managed hot PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_point_queries(&db.client, managed, hot_id).await? }; @@ -657,6 +673,18 @@ async fn run_managed_only_body( status.hot_rows, status.cold_row_count, status.mirror_rows )); + { + let _step = common::log_step_always("storage_cmp: post-flush hot-only PK lookups"); + assert_post_flush_hot_pk_plan(&db.client, managed, hot_id).await?; + } + // Time hot-only / cold / hot+cold after flush but before hot-heap VACUUM so + // the PG baseline (full heap, pre-VACUUM) and managed paths are not mixed + // with a post-FULL-vacuum heap rewrite on the unmanaged side. + let query_hot_after_flush = { + let _step = common::log_step_always("storage_cmp: time managed hot PK after flush"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; + time_point_queries(&db.client, managed, hot_id).await? + }; { let _step = common::log_step_always("storage_cmp: post-flush cold-only PK lookups"); let plan_cold = common::explain( @@ -672,15 +700,14 @@ async fn run_managed_only_body( "cold-only PK lookup should open at least one Parquet segment, got:\n{plan_cold}" ); } - // Time cold / hot+cold after flush but before hot-heap VACUUM so the PG - // baseline (full heap, pre-VACUUM) and managed Parquet path are not mixed - // with a post-FULL-vacuum heap rewrite on the unmanaged side. let query_cold_only = { let _step = common::log_step_always("storage_cmp: time managed cold-only PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_point_queries(&db.client, managed, cold_id).await? }; let query_hot_cold = { let _step = common::log_step_always("storage_cmp: time managed mixed hot+cold PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_mixed_hot_cold_queries(&db.client, managed, hot_id, cold_id).await? }; @@ -734,6 +761,7 @@ async fn run_managed_only_body( update, delete, query_hot_only, + query_hot_after_flush, query_hot_cold, query_cold_only, vacuum, @@ -782,7 +810,7 @@ async fn run_storage_comparison_body( db: &common::TestDb, baseline: &str, managed: &str, - managed_table: &str, + _managed_table: &str, rows: i64, hot_limit: i64, dml_sample: i64, @@ -793,9 +821,10 @@ async fn run_storage_comparison_body( // Apply the source tables' benchmark-only autovacuum control to the // generated mirror. Otherwise a long async catch-up can launch mirror // maintenance during the next timed phase. + let managed_mirror = common::change_log_mirror_relation(managed); db.client .batch_execute(&format!( - "ALTER TABLE koldstore.{managed_table}__cl SET (autovacuum_enabled = false)" + "ALTER TABLE {managed_mirror} SET (autovacuum_enabled = false)" )) .await .context("disable autovacuum on benchmark mirror")?; @@ -878,9 +907,8 @@ async fn run_storage_comparison_body( common::assert_managed_read_plan(&plan_pre_flush)?; anyhow::ensure!( !plan_pre_flush.contains("Custom Scan (KoldMergeScan)") - || plan_pre_flush.contains("Parquet Segments Opened: 0") - || plan_pre_flush.contains("Parquet Segments Planned: 0"), - "pre-flush PK lookup must not open Parquet (no cold yet), got:\n{plan_pre_flush}" + && plan_pre_flush.contains("Index Scan"), + "pre-flush hot PK must use native Index Scan (empty cold → no KoldMergeScan), got:\n{plan_pre_flush}" ); assert_point_row_matches(&db.client, baseline, managed, hot_id).await?; assert_point_row_matches(&db.client, baseline, managed, cold_id).await?; @@ -892,6 +920,7 @@ async fn run_storage_comparison_body( }; let managed_hot = { let _step = common::log_step_always("storage_cmp: time managed hot PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_point_queries(&db.client, managed, hot_id).await? }; @@ -955,14 +984,29 @@ async fn run_storage_comparison_body( assert_point_row_matches(&db.client, baseline, managed, mid_cold_id).await?; } + { + let _step = common::log_step_always("storage_cmp: post-flush hot-only PK lookups"); + assert_post_flush_hot_pk_plan(&db.client, managed, hot_id).await?; + } // Baseline cold/hot+cold on the full heap before VACUUM FULL; managed on // Parquet after flush before hot-heap VACUUM — same contract as isolated sides. + let baseline_hot_after_flush = { + let _step = + common::log_step_always("storage_cmp: time baseline hot PK (post-flush baseline)"); + time_point_queries(&db.client, baseline, hot_id).await? + }; + let managed_hot_after_flush = { + let _step = common::log_step_always("storage_cmp: time managed hot PK after flush"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; + time_point_queries(&db.client, managed, hot_id).await? + }; let baseline_cold_only = { let _step = common::log_step_always("storage_cmp: time baseline cold-only PK lookups"); time_point_queries(&db.client, baseline, cold_id).await? }; let managed_cold_only = { let _step = common::log_step_always("storage_cmp: time managed cold-only PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_point_queries(&db.client, managed, cold_id).await? }; let baseline_hot_cold = { @@ -971,6 +1015,7 @@ async fn run_storage_comparison_body( }; let managed_hot_cold = { let _step = common::log_step_always("storage_cmp: time managed mixed hot+cold PK lookups"); + ensure_async_worker_stopped_for_benchmark(&db.client).await?; time_mixed_hot_cold_queries(&db.client, managed, hot_id, cold_id).await? }; @@ -1028,6 +1073,7 @@ async fn run_storage_comparison_body( update: baseline_update, delete: baseline_delete, query_hot_only: baseline_hot, + query_hot_after_flush: baseline_hot_after_flush, query_hot_cold: baseline_hot_cold, query_cold_only: baseline_cold_only, vacuum: baseline_vacuum, @@ -1041,6 +1087,7 @@ async fn run_storage_comparison_body( update: managed_update, delete: managed_delete, query_hot_only: managed_hot, + query_hot_after_flush: managed_hot_after_flush, query_hot_cold: managed_hot_cold, query_cold_only: managed_cold_only, vacuum: managed_vacuum, @@ -1182,9 +1229,10 @@ async fn warm_up_before_timed_seed( max_rows_per_flush, ) .await?; + let warmup_mirror = common::change_log_mirror_relation(&warmup_relation); client .batch_execute(&format!( - "ALTER TABLE koldstore.{warmup_table}__cl SET (autovacuum_enabled = false)" + "ALTER TABLE {warmup_mirror} SET (autovacuum_enabled = false)" )) .await .context("disable autovacuum on warm-up mirror")?; @@ -1215,10 +1263,7 @@ async fn warm_up_before_timed_seed( } fn mirror_relation(managed: &str) -> Option { - managed - .rsplit('.') - .next() - .map(|table| format!("koldstore.{table}__cl")) + Some(common::change_log_mirror_relation(managed)) } fn schema_sql_path() -> PathBuf { @@ -1292,8 +1337,9 @@ async fn manage_with_hot_limit( } async fn async_catchup(client: &Client, expected_changes: i64) -> Result { - // Stop a launcher-respawned applier so this fence owns the apply work. - let _ = terminate_async_workers_until_idle(client).await; + // Keep the sticky WAL service paused so this fence owns apply work and the + // following timed phase is not charged for a respawned applier. + ensure_async_worker_stopped_for_benchmark(client).await?; let started = Instant::now(); let applied: i64 = client .query_one("SELECT koldstore.wait_for_async_mirror()", &[]) @@ -1311,6 +1357,7 @@ async fn async_catchup(client: &Client, expected_changes: i64) -> Result acknowledged == 0, "async mirror acknowledgement replayed {acknowledged} changes" ); + ensure_async_worker_stopped_for_benchmark(client).await?; Ok(Timing { elapsed: started.elapsed(), ops: expected_changes, @@ -1320,7 +1367,7 @@ async fn async_catchup(client: &Client, expected_changes: i64) -> Result /// Drain async mirror to idle without requiring an exact applied row count. async fn async_catchup_drain(client: &Client) -> Result<()> { - let _ = terminate_async_workers_until_idle(client).await; + ensure_async_worker_stopped_for_benchmark(client).await?; loop { let applied: i64 = client .query_one("SELECT koldstore.wait_for_async_mirror()", &[]) @@ -1330,6 +1377,7 @@ async fn async_catchup_drain(client: &Client) -> Result<()> { break; } } + ensure_async_worker_stopped_for_benchmark(client).await?; Ok(()) } @@ -1354,9 +1402,10 @@ async fn disable_async_retained_wal_health_threshold_for_benchmark( } async fn disable_async_worker_for_benchmark(client: &Client, dbname: &str) -> Result { - // Pin the database setting and the session. The shared-preload launcher - // connects to `postgres`, so it may still restart appliers; callers also - // terminate before each catch-up fence. + // Pin the database/session GUC off so client ensure paths stay dark, then + // pause supervisor dispatch and terminate the sticky WAL applier. GUC-off + // alone is not enough: a required WAL service stays resident and the + // supervisor restarts it unless `ensure_paused` is set. client .batch_execute(&format!( "ALTER DATABASE \"{dbname}\" SET koldstore.internal_async_mirror_worker = off" @@ -1368,28 +1417,30 @@ async fn disable_async_worker_for_benchmark(client: &Client, dbname: &str) -> Re .await .context("disable async mirror worker GUC in benchmark session")?; disable_async_retained_wal_health_threshold_for_benchmark(client, dbname).await?; - terminate_async_workers_until_idle(client).await?; + ensure_async_worker_stopped_for_benchmark(client).await?; Ok(true) } -async fn terminate_async_workers_until_idle(client: &Client) -> Result<()> { - let started = Instant::now(); - loop { - let _ = common::terminate_async_worker(client).await?; - if !common::async_worker_running(client).await? { - return Ok(()); - } - if started.elapsed() >= Duration::from_secs(2) { - common::log_always( - "storage_cmp: async worker still visible after terminate window; continuing with fence", - ); - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } +/// Pauses supervisor WAL/maintenance dispatch and terminates any live applier. +/// +/// Timed storage phases must call this (not a best-effort terminate) so a +/// required WAL service cannot respawn under the supervisor during DML/query +/// measurement. Foreground `wait_for_async_mirror()` fences still apply WAL. +async fn ensure_async_worker_stopped_for_benchmark(client: &Client) -> Result<()> { + common::force_stop_async_worker(client) + .await + .context("pause + stop persistent WAL applier for storage benchmark")?; + anyhow::ensure!( + !common::async_worker_running(client).await?, + "persistent WAL applier still running after force_stop; timed metrics would be contaminated" + ); + Ok(()) } async fn reset_async_worker_guc(client: &Client, dbname: &str) -> Result<()> { + // Release the benchmark pause before restoring GUCs so later sessions are + // not stuck with supervisor dispatch held off. + let _ = common::release_async_worker_stop_lock(client).await; client .batch_execute(&format!( "ALTER DATABASE \"{dbname}\" RESET koldstore.internal_async_mirror_worker; \ @@ -1419,8 +1470,7 @@ async fn assert_async_worker_disabled_for_benchmark(client: &Client) -> Result<( disabled, "async benchmark worker GUC did not remain disabled" ); - // Best-effort: launcher may respawn briefly; catch-up paths terminate again. - let _ = terminate_async_workers_until_idle(client).await; + ensure_async_worker_stopped_for_benchmark(client).await?; Ok(()) } @@ -2450,6 +2500,40 @@ async fn time_point_queries(client: &Client, relation: &str, id: i64) -> Result< Ok(Timing::with_p99(elapsed, QUERY_LOOPS as i64, &samples)) } +/// Asserts a still-hot PK lookup after flush does not open Parquet. +/// +/// Locked contract: prefer native Index Scan when cold bounds prove empty for +/// the PK; otherwise `KoldMergeScan` with Index Planned Access and zero Parquet +/// segments opened/planned. +async fn assert_post_flush_hot_pk_plan(client: &Client, relation: &str, hot_id: i64) -> Result<()> { + let plan = common::explain( + client, + &format!("SELECT id, account_id, event_type FROM {relation} WHERE id = {hot_id}"), + ) + .await?; + common::assert_managed_read_plan(&plan)?; + let native_hot = !plan.contains("Custom Scan (KoldMergeScan)") && plan.contains("Index Scan"); + let merge_hot_no_parquet = plan.contains("Custom Scan (KoldMergeScan)") + && (plan.contains("Parquet Segments Opened: 0") + || plan.contains("Parquet Segments Planned: 0")) + && plan + .lines() + .any(|line| line.contains("Planned Access:") && line.contains("Index Scan")); + anyhow::ensure!( + native_hot || merge_hot_no_parquet, + "post-flush hot PK must be native Index Scan or KoldMergeScan with 0 Parquet segments, got:\n{plan}" + ); + common::log_always(format!( + "storage_cmp: post-flush hot PK plan ({relation} id={hot_id}): {}", + if native_hot { + "native Index Scan" + } else { + "KoldMergeScan hot-only (0 Parquet)" + } + )); + Ok(()) +} + /// After flush: alternate hot PK / cold PK lookups (50/50 of `QUERY_LOOPS`). async fn time_mixed_hot_cold_queries( client: &Client, @@ -2624,11 +2708,21 @@ fn build_comparison_report( pg_p99(baseline.map(|m| m.query_hot_only)), mg_p99(managed.map(|m| m.query_hot_only)), ), + row( + "hot-after-flush-query p99 latency", + pg_p99(baseline.map(|m| m.query_hot_after_flush)), + mg_p99(managed.map(|m| m.query_hot_after_flush)), + ), row( "cold-query p99 latency", pg_p99(baseline.map(|m| m.query_cold_only)), mg_p99(managed.map(|m| m.query_cold_only)), ), + row( + "hot-only query throughput (after flush)", + pg_ops(baseline.map(|m| m.query_hot_after_flush)), + mg_ops(managed.map(|m| m.query_hot_after_flush)), + ), row( "hot+cold query throughput", pg_ops(baseline.map(|m| m.query_hot_cold)), @@ -2773,6 +2867,11 @@ fn build_comparison_report( pg_speed(baseline.map(|m| m.query_hot_only)), mg_speed(managed.map(|m| m.query_hot_only)), ), + row( + "query hot only (after flush)", + pg_speed(baseline.map(|m| m.query_hot_after_flush)), + mg_speed(managed.map(|m| m.query_hot_after_flush)), + ), row( "query with hot+cold (after flush)", pg_speed(baseline.map(|m| m.query_hot_cold)), @@ -2902,10 +3001,12 @@ fn build_comparison_report( )); notes.push( "hot-only PK = newest id before flush (full heap both sides). \ - PG cold-id / hot+cold also run on the full heap before VACUUM FULL. \ - Managed cold-only / hot+cold run after flush (Parquet) before hot-heap VACUUM. \ - Timed INSERT seeds an empty table to `rows` on every side — hot_row_limit \ - does not shrink the insert working set." + query hot only (after flush) = same newest PK after policy flush on managed \ + (still-hot row; must not open Parquet); PG times the same SQL on the full \ + pre-VACUUM heap. PG cold-id / hot+cold also run on the full heap before \ + VACUUM FULL. Managed cold-only / hot+cold run after flush (Parquet) before \ + hot-heap VACUUM. Timed INSERT seeds an empty table to `rows` on every side — \ + hot_row_limit does not shrink the insert working set." .to_string(), ); if baseline.is_none() || managed.is_none() { diff --git a/tests/stress/Cargo.toml b/tests/stress/Cargo.toml index 2de0106a..05a2d877 100644 --- a/tests/stress/Cargo.toml +++ b/tests/stress/Cargo.toml @@ -12,6 +12,7 @@ ignored = [ "koldstore-memory-tests", "koldstore-storage", "koldstore-supervisor", + "koldstore-wal-mirror", ] [[test]] @@ -26,6 +27,7 @@ koldstore-common.workspace = true koldstore-memory-tests = { path = "../memory" } koldstore-storage = { workspace = true, features = ["s3"] } koldstore-supervisor.workspace = true +koldstore-wal-mirror.workspace = true serde.workspace = true serde_json.workspace = true tokio = { workspace = true, features = ["macros", "sync"] } diff --git a/tests/stress/src/support.rs b/tests/stress/src/support.rs index e85ae196..7d51c562 100644 --- a/tests/stress/src/support.rs +++ b/tests/stress/src/support.rs @@ -1,6 +1,6 @@ //! Thin wrappers around shared e2e helpers used by the stress harness. -use anyhow::Result; +use anyhow::{Context, Result}; use tokio_postgres::Client; use crate::e2e; @@ -39,40 +39,74 @@ pub async fn wait_for_jobs(client: &Client, relation: &str) -> Result<()> { /// Force-flushes a managed table and returns rows_flushed from the job row. /// +/// Retries when finalize loses the slot/apply lock race to the busy async +/// applier (same contract as e2e `flush_table_with_force`). +/// /// # Errors /// -/// Returns an error when enqueue/flush/job lookup fails or the job failed. +/// Returns an error when enqueue/flush/job lookup fails or the job failed +/// after slot-lock retries. pub async fn force_flush_table(client: &Client, relation: &str) -> Result { wait_for_jobs(client, relation).await?; e2e::fence_async_mirror(client).await?; - let job_id = e2e::flush_table_job_id(client, relation, true) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "flush_table returned NULL for {relation} (force=true); \ - expected force flush to enqueue work" - ) - })?; - let flushed = e2e::wait_for_flush_job_terminal(client, &job_id).await?; - wait_for_jobs(client, relation).await?; - Ok(flushed) + let mut last_slot_busy: Option = None; + for attempt in 1..=8 { + let job_id = e2e::flush_table_job_id(client, relation, true) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "flush_table returned NULL for {relation} (force=true); \ + expected force flush to enqueue work" + ) + })?; + match e2e::wait_for_flush_job_terminal(client, &job_id).await { + Ok(flushed) => { + wait_for_jobs(client, relation).await?; + return Ok(flushed); + } + Err(error) if e2e::is_flush_slot_lock_contention(&error) => { + last_slot_busy = Some(error); + let _ = e2e::fence_async_mirror(client).await; + tokio::time::sleep(std::time::Duration::from_millis(50 * attempt as u64)).await; + } + Err(error) => return Err(error), + } + } + Err(last_slot_busy.expect("retry loop always records a slot-lock error")).context(format!( + "force flush still blocked by slot lock after retries for {relation}" + )) } /// Policy flush (non-force) returning rows_flushed. /// /// Returns `0` when policy has no due work (`flush_table` returns NULL). +/// Retries when finalize loses the slot/apply lock race to the busy async +/// applier under soak write load (same contract as e2e `flush_table_with_force`). /// /// # Errors /// -/// Returns an error when flush or job lookup fails. +/// Returns an error when flush or job lookup fails after slot-lock retries. pub async fn flush_table(client: &Client, relation: &str) -> Result { - // Policy flush decides from mirror pending counts; catch up WAL apply so - // recently committed DML is visible to the due check. - e2e::fence_async_mirror(client).await?; - let Some(job_id) = e2e::flush_table_job_id(client, relation, false).await? else { - return Ok(0); - }; - e2e::wait_for_flush_job_terminal(client, &job_id).await + // Queue flush executors perform their own bounded catch-up. Do not run the + // strong foreground fence here: under continuous ingest it holds the + // database apply lock while draining a large backlog and starves finalize. + let mut last_slot_busy: Option = None; + for attempt in 1..=8 { + let Some(job_id) = e2e::flush_table_job_id(client, relation, false).await? else { + return Ok(0); + }; + match e2e::wait_for_flush_job_terminal(client, &job_id).await { + Ok(rows) => return Ok(rows), + Err(error) if e2e::is_flush_slot_lock_contention(&error) => { + last_slot_busy = Some(error); + tokio::time::sleep(std::time::Duration::from_millis(50 * attempt as u64)).await; + } + Err(error) => return Err(error), + } + } + Err(last_slot_busy.expect("retry loop always records a slot-lock error")).context(format!( + "flush_table still blocked by slot lock after retries for {relation}" + )) } /// Registers a user-scoped managed table with aggressive small-file flush policy. diff --git a/tests/stress/src/workload.rs b/tests/stress/src/workload.rs index 60f28774..cb82ab42 100644 --- a/tests/stress/src/workload.rs +++ b/tests/stress/src/workload.rs @@ -612,7 +612,9 @@ pub async fn assert_post_soak( ) -> Result<()> { e2e::fence_async_mirror(client).await?; for relation in schema.managed_relations() { - e2e::assert_no_active_jobs(client, relation).await?; + // Auto-flush / late flush-rider jobs can still be draining after soak + // stop; wait instead of one-shot asserting an empty queue. + crate::support::wait_for_jobs(client, relation).await?; } let tenant = config.tenant_id(0); diff --git a/third_party_licenses.hbs b/third_party_licenses.hbs new file mode 100644 index 00000000..6238a639 --- /dev/null +++ b/third_party_licenses.hbs @@ -0,0 +1,26 @@ + + + + + KoldStore third-party notices + + +

KoldStore third-party notices

+

+ This file is generated from the locked Rust dependency graph. It applies to + the KoldStore release artifact that contains this file. +

+ {{#each licenses}} +
+

{{name}} ({{id}})

+

Used by

+
    + {{#each used_by}} +
  • {{crate.name}} {{crate.version}}
  • + {{/each}} +
+
{{text}}
+
+ {{/each}} + +