migration: batched postgres inserts and validation#93
Open
djkazic wants to merge 1 commit into
Open
Conversation
Contributor
|
Really nice optimization, thank you. Lets split this up in 3 PRs:
This keeps SQL schema knowledge inside lnd instead of lndinit. It also fits the longer-term plan to move migration code into lnd, so this becomes reusable migration infrastructure rather than a lndinit-only hot fix.
Keep lndinit as orchestration only:
Treat the |
Contributor
|
replaced with #94 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
Migrating an lnd bolt database (for example channel.db) into postgres is very slow. We measured about 4.5 hours for ~15M keys. Profiling showed the process was almost entirely idle waiting on the network (CPU around 6 percent), not busy doing work.
The cause is that the migration talks to postgres one row at a time, and it does so twice. The migrate phase issues one INSERT per key, and the verify phase reads the target back one query at a time. Each of those is a separate round trip on a single connection, so total time is roughly
round_trips * round_trip_latency, whereround_tripsscales with the number of keys and buckets. It scales linearly with database size and with network latency, which is why co-locating the DB helps a little but does not fix the underlying problem.This PR reduces the number of round trips in both the migrate and verify phases.
Changes
migratekvdb/bulk.go, newMigrateBulkSQL)Adds a postgres write path that inserts leaf rows in batches (1000 rows per statement by default) using a raw pgx transaction, instead of one Put per key through kvdb. It walks the source depth first, creates bucket rows as it goes so the parent_id foreign key is always satisfied, and streams leaf key/values into multi-row INSERTs. The whole migration runs in a single transaction. It does not modify or fork the kvdb dependency.
This path is for fresh migrations only. The existing Migrate still handles resume.
Because the SQL target and the bolt meta database cannot share a transaction, completion is not perfectly atomic across the two. To make an interrupted run recoverable instead of leaving it stuck,
MigrateBulkSQLwrites an in-progress marker to the meta DB before it starts writing data, and clears it after the SQL commit and the finished-state write. On a re-run, if the marker is still set, it truncates the target table and starts over, which is safe because the marker means the previous attempt owned the table. This adds two small meta writes and no measurable overhead on the hot path.Batch size is clamped to 65535 / 3 rows so a caller cannot exceed the postgres
bind-parameterlimit.migratekvdb/migration.go)Two changes to the existing verify path:
migratekvdb/verify_batched.go, newVerifyBatchedSQL)The single-ForAll-per-bucket verify above still issues a few queries per bucket, so on a database with a very large number of buckets (a real channel.db has on the order of a million) the verify phase is dominated by per-bucket round trips.
VerifyBatchedSQLwalks the tree breadth first and fetches the direct children of up tobatchSizebuckets in one query (WHERE parent_id = ANY(...)), instead of one query per bucket. That reduces the query count from roughlybucket_countto roughlybucket_count / batchSize. The batch result is streamed(ORDER BY parent_id, key)and compared against each parent's source cursor inline, so a batch that happens to include a very wide bucket does not buffer that bucket's rows in memory. Every query is a plain index scan on(parent_id, key), so there is no server-side sort and nowork_mempressure, and client memory is bounded by one BFS level. Theparent_idarray is passed as a single bound parameter so the SQL text is constant and pgx reuses one prepared statement. Verification error messages print keys and paths only, never values.This is a fresh-only path for a postgres target reached through a raw
*sql.DB. Bolt targets and resume continue to use the hierarchical path.cmd_migrate_db.go)A new
--bulk-writesflag enables the fast postgres path for a fresh migration:MigrateBulkSQLfor the migrate phase andVerifyBatchedSQLfor the verify phase. It is opt-in and postgres only. Any other destination logs a warning and falls back to the standardMigrateandVerifyMigrationpath. A prior interrupted bulk run self-recovers as described above; a prior interrupted non-bulk run that already wrote data will surface a "target not empty" error rather than silently proceeding.Results
We ran the actual lndinit migrate-db
--bulk-writescommand end to end against a copy of a real mainnet data directory.The dominant remaining cost is bucket creation during the migrate phase, which still issues one INSERT ... RETURNING id per bucket. Batching that is a sensible follow-up but is out of scope here.
One behavior change worth calling out: a fresh sqlite or postgres verify no longer checkpoints mid-bucket, so if it is interrupted while verifying a large top-level bucket, the resume re-verifies that bucket from the start. Resume correctness is preserved, only repeated work changes. Bolt was never on this path.
Testing
New: an edge-case source builder (empty values, binary keys, prefix-overlapping keys, explicit sequences, an empty bucket, three-level nesting with leaves and sub-buckets interleaved) plus an independent byte-for-byte comparator.
A sqlite test verifies the empty-value handling (regression test for the nil-versus-empty case, where sqlite decodes an empty stored value as nil while bolt and postgres return an empty slice). A postgres test covers bulk migrate correctness, batch-size clamping, refusal on a non-empty target, crash recovery via the in-progress marker, batched verification (including a small batch size to exercise batch and level boundaries), and negative cases confirming both verify paths detect a missing row, a mutated value, and an extra row. An end-to-end test runs the full command with
--bulk-writes. All pass under the base, kvdb_sqlite, and kvdb_postgres tags.