Skip to content

The managed store sets random_page_cost = 1.1, tuned for SSD storage - #4863

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/managed-store-random-page-cost
Sep 30, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/managed-store-random-page-cost

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

The managed store's darling-managed.conf now carries random_page_cost = 1.1. It also adds two sentences to the bring-your-own-store section of Darling/README.md.

Why

  • On a large production monitoring store (PostgreSQL 18.6, EBS gp3 volumes, 3,000 to 6,000 provisioned IOPS, 250 MB/s), the default random_page_cost of 4 made the planner price a BRIN index's lossy heap pages as random reads. It chose a sequential scan over query_store_interval_wide for a 12-hour window (3.2M blocks), even with a BRIN index on collection_time.
  • SET LOCAL random_page_cost = 1.1 moved the 6 h, 12 h and 48 h windows onto the BRIN index (Build a BRIN index on query_store_interval_wide (collection_time) in the background #4862). A 24 h read took 4.3 s warm (about 37 s cold, at the volume's throughput cap) instead of 98.8 s cold on the full scan.
  • Why 1.1, not 1.0: seq_page_cost stays at 1.0. The PG18 docs (runtime-config-query) say the 4.0 default assumes most random reads are cached. They say network-attached storage latency shrinks the relative cost of random access, and that lowering the value suits mostly cached data or high latency. They also say a value below seq_page_cost "is not physically sensible", and that equal values only make sense for a database entirely in RAM. The docs give no SSD figure, so 1.1 is the measured value, not a documented one.

What changed

  • DarlingManagedPostgres.BuildPlannerPageCostConfAppend() is a new block with no marker. ManagedConfFile.RenderBody appends it, so the managed file is the only place the setting is written.
  • A store migrating off the legacy postgresql.conf blocks gets the line in the same migration that writes the managed file, so it converges in one start: ManagedConfFile.ManagedOnlyKeys names random_page_cost, and RenderWithValues writes its derived value only when the store's own file settings have none, so an operator's value in postgresql.conf is still carried. The migration's verification (ManagedConfFileSettings.Compare) accepts exactly that one addition at exactly that value; any other added or changed setting still fails it. No legacy block is appended for it.
  • The README's bring-your-own-store paragraph now recommends random_page_cost = 1.1 on SSD-backed storage. It also says the BRIN index on the per-interval Query Store table is skipped below PostgreSQL 16, because HOT updates past a BRIN index need PostgreSQL 16 or newer.

Upgrade path

  • Store on the previous version's render: its body hash matches its body, so IsHandEdited is false. The new body differs, so ShouldReplaceManagedConf is true and the next service-owned start rewrites the file with the line. The rewrite happens in EnsureManagedConfReadyAsync, before pg_ctl start, so the start that renders it also applies it. Nothing reloads a running store on its own. random_page_cost is user-context, so a reload would apply it, but the render only takes effect at the next service-owned start.
  • Store migrating off the legacy blocks: the line is in the managed file from its first start. The migration does not reload, so on that store the value is in force from the next start.
  • Hand-edited file: unchanged behaviour. It is never overwritten, and the start logs the differing keys. It now also lists random_page_cost (absent in the file, 1.1 rendered).
  • postgresql.auto.conf: nothing reads or writes it. An operator's ALTER SYSTEM still wins over the managed file.
  • An operator line in postgresql.conf: a random_page_cost set below the include line still wins. One set ABOVE the include line is now overridden by the managed file on a store whose conf already migrated, because the key was not managed when that migration ran. Stock files carry only the commented-out default. An operator who set it above the include should move it below the include or use ALTER SYSTEM.
  • Last-good fallback: the fallback file is a copy of a previously accepted render. A rejected render restores it, exactly as before. random_page_cost = 1.1 is valid on every supported PG, so this path is not new.

Audit (PG18 docs)

Only random_page_cost changes. The rest is deliberate:

Setting PG18 doc evidence Rendered today Recommendation Changed
random_page_cost runtime-config-query: default 4.0, assumes most random reads are cached. not rendered (default 4.0) 1.1 (measured, above) yes
seq_page_cost Same page: default 1.0, and random_page_cost below it "is not physically sensible". not rendered (1.0) Keep 1.0. It is the reference the other cost settings are relative to. no
effective_cache_size Same page: the planner's assumption about the cache available to one query, covering shared buffers and the OS cache. A higher value makes index scans more likely. 75% of RAM (v3/v8 formulas) Keep. It is already RAM-derived and reasonable. no
effective_io_concurrency runtime-config-resource: default 16 in PG18. Higher values help "higher latency storage" and "devices with high IOPs". "Unnecessarily high values may increase I/O latency for all queries". not rendered (16) Keep the default. It is already high, gp3 is provisioned at 3,000 to 6,000 IOPS, and any change needs a workload measurement. no
maintenance_io_concurrency Same page: default 16. not rendered (16) Keep. no
io_method Same page: worker (default), io_uring, sync. Postmaster-context, restart only. io_uring needs a Linux build with liburing. not rendered (worker) The managed store runs on Windows: io_uring is not available there, and worker is the only useful choice. Keep the default. no
io_workers Same page: default 3, used when io_method = worker. not rendered (3) Keep. Needs a measurement before anything else. It is a reload (sighup) setting, not a restart. no
io_combine_limit / io_max_combine_limit Same page: the largest I/O size in operations that combine I/O. A io_combine_limit above io_max_combine_limit is silently capped to it. not rendered (defaults) Keep. There is no measured need. no
max_worker_processes Same page: default 8, restart only. v8 formula (workers, TimescaleDB background workers) Keep. Already RAM- and hypertable-sized. no
max_parallel_workers Same page: default 8. Adjust with the per-gather and maintenance limits. not rendered (8) Keep. Its ceiling is max_worker_processes. no
max_parallel_workers_per_gather / max_parallel_maintenance_workers Same page: workers per Gather node, and per utility command (B-tree, GIN and BRIN builds, VACUUM). not rendered (defaults) Keep. Raising them competes with the TimescaleDB background workers on the same pool. no
parallel_setup_cost / parallel_tuple_cost / min_parallel_table_scan_size runtime-config-query: defaults 1000, 0.1, 8MB. not rendered Keep. Each can change plans fleet-wide and needs a plan diff on production statements first. no

None of these has a clear, unmeasured win. Everything except random_page_cost stays out. Several need a restart, and each needs production measurement first.

--check-settings (README, "Check Host/Store Sizing Settings") lists RAM-, hypertable- and disk-derived settings. random_page_cost is a fixed constant, not a derivation, so that list is unchanged.

Pins (RED and GREEN)

The tests are in ManagedConfFileTests. I ran them in-process on macOS (runtime-config edit as documented).

  • RenderBody_ContainsRandomPageCost_ExactlyOnce_At1Point1 checks the body and the full file text.
    • GREEN on this branch.
    • RED under the mutation 1.1 to 4: Expected "random_page_cost = '1.1'", Actual "random_page_cost = '4'".
    • RED with the builder call removed (dev behaviour): Assert.Single fails on zero lines.
  • PreviousVersionRender_IsNotHandEdited_AndIsReplacedWithTheNewLine builds the previous render from the new body minus the line, with a matching hash. It asserts IsHandEdited is false and ShouldReplaceManagedConf is true.
    • GREEN on this branch.
    • RED with the builder call removed (no difference to replace).
  • HandEditedPreviousVersionRender_IsLeftInForce_AndTheDiffNamesRandomPageCost asserts a hand edit is detected and not replaced, and the diff names random_page_cost.
    • GREEN on this branch.
    • RED under both mutations (the diff assertion fails).
  • ManagedConfRehearsalTests managed-key count moved 26 to 27 (69 lines removed unchanged). GREEN, 6 of 6.
  • ManagedConfFileTests: 38 total, 2 failures. Both are kernel32.dll P/Invoke in WriteManagedConfFile_* and fail the same way on dev; they are Windows-only.
  • Ran on macOS with 0 failures: ManagedConfRehearsalTests, ManagedConfMigrationTests, ManagedConfMigrationStateTests, ManagedConfMigrationRunnerTests, DocCommentHygieneTests, CommentFilterAdoptionTests, RepoFileAdoptionTests, McpToolsListBudgetTests, and every *Census*, *AdoptionTests and *CommandTimeoutTests class (33 classes).
  • Environment-only failures, present with or without this change: 14 in DarlingManagedPostgresTests (Windows paths or kernel32) and 1 in ManagedConfUpgradePathTests (kernel32).
  • Live test: nothing runs the bundled PostgreSQL on macOS. On Windows CI (where DARLING_TEST_PGRUNTIME is set), the existing ManagedConfFileLiveTests.FirstStartMigrates_SecondStartSourcesFromManagedConf_Gated starts the bundled server on a fresh render and asserts that every key in the managed body sources from darling-managed.conf in pg_settings. That set now includes random_page_cost, so CI proves the line loads and is in force. No new live test was added.
  • The Darling.Tests build is 0 warnings and 0 errors. Lite.Tests is not affected: Lite does not reference this render.

Plan regression screen (production)

  • Generic plans (EXPLAIN only, the top 100 service statements): at 1.1, 53 of 73 plannable statements change plan on one production store and 46 of 67 on a second. The changes are the usual SSD shifts: bitmap heap scans become index scans, seq scans on compressed chunks become index scans, Sort + GroupAggregate becomes HashAggregate, and more ChunkAppend. A changed plan is not a measured speed, which is why the next check exists.
  • Live A/B (measured): a production store monitoring 43 servers ran an hour at random_page_cost = 1.1 and then an hour back at 4, back to back, at the same collection cadence, with no test load (a reversible ALTER SYSTEM plus reload; an earlier read was set aside because test load ran in both windows). Across the 171 statements with at least 5 calls in both windows, weighted at the same call counts, total statement time was 892 s at 1.1 against 913 s at 4 (−2.3%). Faster at 1.1, for example: the wait-type summary (0.30 against 5.64 ms per call over about 3,950 calls an hour, 36 against 1,426 buffers), the per-type wait trend (18 against 69 ms) and the plan-cache ranking (0.15 against 13.6 ms). Slower at 1.1, about 20 s an hour in all: the per-server MAX(last_execution_time), MAX(collection_time) read of query_store_stats (30 against 22 ms; a plan change, 37,778 against 4,611 buffers per call), the plan-map liveness touch (92 against 61 ms), a collector_state delete (71 against 33 ms, same buffers) and the plan-dimension digest fetch (307 against 216 ms). The heaviest statement, the Query Store raw dedupe, is unchanged. The 12-hour-and-longer Query Store panels switch from a full scan to the BRIN index. The slower statements are a tuning note for after the release.
  • Merge order: the README sentence about the BRIN index describes Build a BRIN index on query_store_interval_wide (collection_time) in the background #4862. Merge Build a BRIN index on query_store_interval_wide (collection_time) in the background #4862 first, or both together.

CHANGELOG

SECTION: Changed
ENTRY: - The managed store sets random_page_cost = 1.1 ([#4863]) - the managed PostgreSQL store now renders random_page_cost = 1.1, a value suited to SSD storage, in place of PostgreSQL's default of 4, which prices a random page read at four sequential ones. The planner now picks index and bitmap scans that the default priced out. Measured on a production store over an hour at each value, back to back, total statement time was 2.3% lower at 1.1, with some statements faster and a few slower. A store upgrading from 3.8 moves its settings into the managed file at the upgrade start and gets the line there, and the value is in force from the service's next start after that. A random_page_cost set with ALTER SYSTEM still wins, and one already set in postgresql.conf when the store upgrades is carried into the managed file unchanged.
REF: [#4863]: #4863

Findings by class

1 substantial (the planner cost, fixed here). 5 hygiene or polish fixed in this PR: a migrating store needed two starts to settle its managed file (the first start dropped the new line and the second rewrote it), a test assert that compared the new body with itself, a doc comment and two test comments. 0 dropped.

With the default of 4 the planner prices a BRIN index's lossy heap pages as random reads and chose a sequential scan for a 12-hour Query Store window. The managed settings file now carries random_page_cost = 1.1; a file written by the previous version is re-rendered with the line and is not treated as hand-edited.
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 30, 2026 15:57
@erikdarlingdata erikdarlingdata changed the title Managed store renders random_page_cost = 1.1 for its SSD-backed volumes The managed store sets random_page_cost = 1.1, tuned for SSD storage Sep 30, 2026
…ore converges in one start

Step A wrote darling-managed.conf from the before-snapshot and dropped every owned key the snapshot lacked. random_page_cost has no legacy block, so a store migrating off the legacy blocks lost the line and its next start rewrote the file. Step A now writes the derived value for keys only the managed file carries, and its verification accepts exactly that addition and nothing else.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant