0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 20:33:03 +08:00
discourse/migrations
Gerhard Schlager 7e30fa8196 MT: Replace the static thread factor with an adaptive worker gate
This is the last of the upload-tooling PRs, and it deletes the one setting
I could never pick a good default for: thread_count_factor.

The benchmarks (mt/upload-bench-harness) made the problem obvious. For the
exact same code, the best worker count runs from about 8 (local store,
CPU-bound image cooking) up to about 24 (an 8-core box pushing to S3, where
every worker just parks on PUT latency). No single factor is right in both
places, so instead of guessing I let the pipeline find the level while it
runs and adjust as it goes.

Throughput is the primary signal, not CPU. On the local-store image path the
box tops out at only 27-47% CPU while throughput has already flatlined, because
the real ceiling is the single writer thread plus the GVL-serialized Ruby per
upload, not the cores. So "CPU still has headroom" does not mean more workers
will help. The controller watches items/s and, when two increases in a row each
buy less than ~5%, it reverts the last step and holds for a while before probing
again. That plateau guard is what actually catches the ceiling, whatever the
cause (writer thread, GVL, subprocess saturation, S3 bandwidth). CPU stays as a
guard rail: over ~95% it backs off, then waits a couple of ticks because the
in-flight convert subprocesses lag the signal.

Memory comes first, ahead of everything, because an OOM kills the run and a bit
of oscillation does not. When available memory drops near empty the controller
halves the target (allowed below the normal floor) and freezes increases for a
few ticks; when it is merely low it just blocks increases for that tick. It
reads both /proc/meminfo and the cgroup v2 limit and takes the tighter of the
two, so a container capped well under the host RAM is respected. As a second
layer, since one pathological convert can balloon within a single 2s tick, each
task also sets MAGICK_MEMORY_LIMIT/MAGICK_MAP_LIMIT to a sane bound at startup
(unless the operator set one, and never above what policy.xml allows). The
existing max_image_megapixels caps the decode size on top of that.

Pieces:

- WorkerGate: an adjustable semaphore. The pipeline spawns ceiling-many worker
  threads once; a worker takes a permit around each item, so raising or lowering
  the target is just workers waking or parking at the next item boundary, no
  threads killed or respawned. The permit is taken outside with_connection, so a
  parked worker never pins an AR connection.
- ResourceSampler: CPU busy from /proc/stat (which sees the ImageMagick children
  and Postgres, not just our threads), with a Process.times fallback where procfs
  is missing; memory headroom from meminfo + cgroup v2. cgroup v1 is deliberately
  skipped (noted in the code) since the tooling only runs on v2 or bare metal.
- AdaptiveController: the ~2s control loop, with the seed and hard bounds in one
  place. Seed is today's heuristic (usable_cpus * 1.5 * store_factor) so it
  starts familiar; ceiling is min(ar_pool - 8, (external ? 16 : 4) * usable_cpus,
  fd_headroom), and it raises the NOFILE soft limit to the hard one at startup.

I moved WorkerBudget.usable_cpus to a shared Migrations::SystemInfo, since the
converter and the uploads pipeline now both need it.

Not built, on purpose: a PID controller (overkill for a signal this noisy),
loadavg (the 1-minute EMA is far too laggy for 2s ticks), and per-worker
blocked-state introspection.
2026-07-11 00:34:49 +02:00
..
bin MT: Split the migrations tooling into separate gems (#40492) 2026-06-02 22:20:03 +02:00
converters MT: Rename IntermediateDB uploads table to upload_sources 2026-07-10 20:50:03 +02:00
core MT: Replace the static thread factor with an adaptive worker gate 2026-07-11 00:34:49 +02:00
docs MT: Add disco check — a single entrypoint for all schema and converter checks 2026-06-11 21:27:17 +02:00
importer MT: Replace the static thread factor with an adaptive worker gate 2026-07-11 00:34:49 +02:00
tooling MT: Support ignoring all other tables in schema configs 2026-07-10 21:24:17 +02:00
.gitignore MT: Split the migrations tooling into separate gems (#40492) 2026-06-02 22:20:03 +02:00
.reek.yml MT: Refactor schema configuration from YAML to Ruby DSL 2026-03-19 18:10:26 +01:00
.rubocop.yml MT: Add a TUI progress reporter and use it in the importer too 2026-06-25 17:46:04 +02:00
AGENTS.md MT: Add disco check — a single entrypoint for all schema and converter checks 2026-06-11 21:27:17 +02:00
CLAUDE.md MT: Refactor schema configuration from YAML to Ruby DSL 2026-03-19 18:10:26 +01:00
README.md MT: Run conversion steps concurrently and split the heavy ones across cores 2026-07-03 11:48:25 +02:00

Migrations Tooling

The migrations/ directory is split into four path-referenced gems:

  • core/Migrations::*: CLI framework, UI, SQLite schemas, DB infrastructure, IntermediateDB models, and the conversion framework (Migrations::Conversion::*).
  • tooling/Migrations::Tooling::*: the schema DSL, disco schema commands, benchmarks.
  • converters/Migrations::Converters::*: public converter implementations + source adapters.
  • importer/Migrations::Importer::*: the row importer and the uploads importer.

All four are wired into the root Gemfile via path: in the optional :migrations group.

Command line interface

The single binary is migrations/bin/disco (commands register dynamically via Migrations::CLI::Registry). Run it without arguments — or with --help — for the authoritative, always-current list of commands:

migrations/bin/disco --help

Rails is booted lazily: only commands that declare requires_rails! (import, upload, schema) load the Discourse app.

Converters

Public converters live in converters/lib/migrations/converters/. To run a private (closed-source) converter, put its code in a subdirectory of private/converters/ (or point MIGRATIONS_PRIVATE_CONVERTERS_PATH at it).

Source DB adapters and fork safety

Worker processes inherit the source DB connection's socket from the main process. Whether that's dangerous depends on the client library: a destructor that only closes the file descriptor is harmless (the parent still holds it, so the kernel sends nothing over the wire), but a destructor that writes a protocol goodbye kills the parent's session as soon as a worker exits — libpq sends a Terminate message, MySQL clients send COM_QUIT.

Adapter::Postgres handles this by registering a ForkManager.after_fork_child hook that calls discard! in each worker: the inherited socket is redirected to /dev/null, and any later use of the adapter in the worker raises DiscardedError. New adapters should follow the same pattern. The discard mechanism itself is library-specific — mysql2 has automatic_close = false, trilogy has a native discard!. To check whether a library needs one at all: connect, fork an empty child that exits normally, wait for it, and query again from the parent (see the fork-safety specs in postgres_spec.rb).

Partitioning large steps

Most steps run in a single worker. A handful are large enough that it's worth splitting them across CPU cores, so the framework can run one worker per chunk of the source. A step opts in from its source block:

source do
  reads_table "topic_users", where: "user_id > 0"
  partition_by :topic_id
end

reads_table is the part that reads a whole table: it defines items (SELECT * FROM topic_users WHERE …) and max_progress (the row count), filtered by where. It works on its own, without partitioning — a plain table-copy step declares just reads_table and writes neither method. partition_by adds the split: it takes the key (normally a single indexed column, so each chunk is an index range scan; pass an array for a composite key) and reuses the table and filter from reads_table, so it only needs the column. When both are present the generated queries add the chunk to their WHERE automatically.

Override items when you need specific columns, a join, or a particular order — and then add partition_slice to its WHERE yourself:

def items
  @source_db.query("SELECT id, name FROM topic_users WHERE #{partition_slice} AND name IS NOT NULL")
end

The framework does the rest. Before forking, it asks the adapter for the chunk boundaries — evenly sized chunks over a numeric key, or a sorted-key scan for a text/UUID/composite key. It then forks one worker per chunk; each worker reads its [lower, upper) slice (that's what partition_slice expands to), writes its own SQLite shard, and the shards are merged back into the run database.

Two things to get right:

  • In a custom query, add partition_slice to the WHERE. Miss it and each worker reads the whole source instead of its slice — duplicated work and wrong counts.
  • Only partition order-independent steps. Workers run concurrently and their output is merged, so there is no global order across the step. A running total or a sequence number across all rows can't be partitioned. Deduplication can, but do it in the source query (DISTINCT ON, a window function, a view) and partition on the dedup key, rather than keeping state in process.

Schema DSL

The schema DSL lives in migrations/tooling/lib/migrations/tooling/schema/dsl/. Config sources are in migrations/tooling/config/schema/. Generated artifacts (SQL, models, enums) are written into migrations/core/.

Key files:

  • table_builder.rb - DSL for defining table configs
  • schema_resolver.rb - Resolves DSL config + DB introspection into final schema
  • conventions_builder.rb - Global column conventions (renames, type overrides)
  • generator.rb - Generates SQL, models, and enums from resolved schema
  • validator.rb - Validates DSL config
  • resolved_schema_validator.rb - Validates resolved schema before generation

Development

Installing gems

bundle config set --local with migrations
bundle install

Updating gems

bundle update --group migrations

Running tests

Each gem has an isolated, no-Rails suite, run from the gem directory:

cd migrations/core       && bundle exec rspec
cd migrations/tooling    && bundle exec rspec
cd migrations/converters && bundle exec rspec
cd migrations/importer   && bundle exec rspec

Specs that need a booted Rails environment are tagged :rails. They are excluded by default and run from the host app's bundle:

cd migrations/<gem> && BUNDLE_GEMFILE=../../Gemfile MIGRATIONS_RAILS=1 bundle exec rspec --tag rails

Linting

bin/lint path/to/file
bin/lint --fix path/to/file

Uses both rubocop and syntax_tree. Always lint changed files.