Skip to content

fix(merge): clean secondary indexes after conflict-resolved merge#1033

Merged
timsehn merged 6 commits into
masterfrom
fix/conflicts-resolve-index-cleanup
May 22, 2026
Merged

fix(merge): clean secondary indexes after conflict-resolved merge#1033
timsehn merged 6 commits into
masterfrom
fix/conflicts-resolve-index-cleanup

Conversation

@timsehn
Copy link
Copy Markdown
Collaborator

@timsehn timsehn commented May 22, 2026

Summary

Three-way merge over a table with secondary indexes used to leak the rejected side's index entries when a row-level conflict resolved as ours's value. After dolt_conflicts_resolve --ours and commit, scans through the index returned phantom rows — e.g. an iv covering scan showed 4 entries for a 3-row table because theirs's stale entry for the conflict row was never filtered.

Diagnosed by direct byte-comparison of the native iv INSERT path's output (sortKeyFromIntRecordLocal in prolly_btree.c) versus what buildIndexSortKey produced in the merge path.

Two interlocking bugs

1. Standalone three-way diff over each index tree. mergeCatalogPass1 iterated index entries as if they were tables and three-way merged each one independently from the parent table's row merge. The diff saw theirs's (v=2, rowid=1) as a benign RIGHT_ADD — different sort key from ours's (v=3, rowid=1) — and added it to the merged index. The index merge had no concept of "table-level conflict resolved to ours."

2. Format mismatch in the inline index merge. The TABLE row-merge callback (which correctly skips conflict rows) wrote index edits via buildIndexSortKey. For IPK tables, the row record stores the IPK column as NULL (a ghost placeholder — the actual rowid lives in the b-tree intkey). buildIndexSortKey operated on the row record directly, so it produced a sort key with a 1-byte NULL marker where the native index format has a 9-byte rowid sortkey. The inline-merged root had keys that didn't match what covering-index scans expected.

Fix

  • buildIndexSortKey now takes intKey + iPKey and substitutes the intKey value for the IPK ghost field, matching the native format from sortKeyFromIntRecordLocal.
  • Non-indexed columns are no longer included in the synthesized key — they aren't in the native format either.
  • mergeCatalogPass1 collects inline-merged index roots across iterations and applies them at the end of the pass, overriding the standalone results.

This matches Dolt's own architecture in go/libraries/doltcore/merge/: no standalone three-way index merge; secondary indexes flow exclusively from table row events; conflict rows skip the secondary-index path so ours's entries remain.

Test plan

  • test/doltlite_merge_index_conflict.sh — 8 cases: conflict + --ours (single index), --theirs (table state only — see limitation below), non-conflict merge, two indexes on one table with conflict on a different column, DELETE-vs-MODIFY conflict resolved --ours
  • All 64 doltlite feature suites pass
  • doltlite_merge.sh (91 cases), doltlite_conflicts.sh (40), doltlite_schema_merge.sh (76), doltlite_savepoint.sh (128), doltlite_conflict_rows.sh (29) all pass

Known limitation

dolt_conflicts_resolve --theirs writes theirs's row via doltliteApplyRawRowMutation, which only updates the table — secondary indexes are not maintained. The merged table state is correct but the index keeps ours's stale entry for the resolved row. This is a separate pre-existing bug at the conflict-resolve layer; the fix would be to thread index maintenance through doltliteApplyRawRowMutation. Tracked separately.

🤖 Generated with Claude Code

Tim and others added 3 commits May 22, 2026 12:18
Three-way merge of a table with secondary indexes used to leak the
rejected side's index entries when a conflict row resolved as ours's
value. Two interlocking bugs:

1. mergeCatalogPass1 ran a STANDALONE three-way diff over each index
   tree, independent of the table-level merge. That path saw theirs's
   (v=2, rowid=1) as a benign RIGHT_ADD — different sort-key from
   ours's (v=3, rowid=1) — and dropped it into the merged index even
   though the row's table value resolved to ours.

2. The INLINE index merge driven by the table row-merge (which DOES
   skip conflict rows) wrote entries with a malformed key/value
   format: for IPK tables the row record stores the IPK column as
   NULL (a ghost placeholder, since the real value lives in the
   intkey), so buildIndexSortKey produced a sort key with 1 byte of
   NULL marker where the native index format has the 9-byte rowid
   sortkey. The inline result couldn't be substituted for the
   standalone result without breaking covering-index scans.

Fix:

- buildIndexSortKey now takes intKey + iPKey and substitutes the
  intKey value for the IPK ghost field, matching the native index
  format written by sortKeyFromIntRecordLocal in prolly_btree.c
  (key = sortkey(indexed_cols, IPK), value = empty).
- Non-indexed columns are no longer included in the synthesized
  key — they aren't part of the native format either.
- mergeCatalogPass1 now collects inline-merged index roots across
  iterations and applies them at the end of the pass, overriding
  the standalone results. This matches the architecture used by
  Dolt itself (no standalone three-way index merge; indexes flow
  from table row events).

Verified against the native bytes captured from the SQL INSERT path
for both single- and multi-column tables.

Known limitation surfaced: dolt_conflicts_resolve --theirs writes
theirs's row via doltliteApplyRawRowMutation, which bypasses index
maintenance. The merged-table state is correct but the secondary
index keeps ours's entry for the resolved row. Separate bug at the
conflict-resolve layer; not addressed here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…--theirs

Extends the merge-time index fix to cover the --theirs path. Before,
dolt_conflicts_resolve --theirs called doltliteApplyRawRowMutation,
which wrote theirs's row to the table tree only — secondary indexes
kept the entry from ours's value (which the merge had taken for the
conflict row), so after the resolve the index pointed at a stale row
value.

doltliteApplyRawRowMutation now:
- Reads the row's current value from the table tree before mutating
- Iterates pTab->pIndex, building (old, new) sort keys via the same
  doltliteBuildIndexSortKey helper the merge uses
- Applies the old-key DELETE + new-key INSERT against each index's
  prolly tree

The static-to-public refactor: doltliteBuildIndexSortKey,
doltliteIpkSerialType, and doltliteIpkWriteBE move from file-static
in doltlite_merge.c to extern; declared in doltlite_internal.h.
prolly_btree.c can't include that header (TableEntry/SchemaEntry
shape conflict with its local definitions), so it forward-declares
the one symbol it needs locally.

Test (doltlite_merge_index_conflict.sh) covers the previously-stale
--theirs case (theirs_iv_three_rows), bringing the suite to 9 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ve-index-cleanup

# Conflicts:
#	test/run_doltlite_tests.sh
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Sysbench-Style Benchmark: Doltlite vs SQLite

In-Memory

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 23,988 34,281 1.43
oltp_range_select 10,481 13,865 1.32
oltp_sum_range 9,649 13,194 1.37
oltp_order_range 2,681 3,145 1.17
oltp_distinct_range 3,796 4,170 1.10
oltp_index_scan 3,944 6,149 1.56
select_random_points 10,412 16,997 1.63
select_random_ranges 3,016 5,131 1.70
covering_index_scan 4,262 4,300 1.01
groupby_scan 32,922 36,441 1.11
index_join 5,764 8,644 1.50
index_join_scan 3,338 5,359 1.61
types_table_scan 1,106,060 1,389,058 1.26
table_scan 1,244,925 1,584,722 1.27
oltp_read_only 103,787 131,023 1.26
Average 1.35

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 178,562 235,852 1.32
oltp_insert 15,601 27,432 1.76
oltp_update_index 50,710 101,050 1.99
oltp_update_non_index 34,961 68,609 1.96
oltp_delete_insert 44,109 77,914 1.77
oltp_write_only 21,986 49,489 2.25
types_delete_insert 24,530 44,803 1.83
oltp_read_write 65,457 118,220 1.81
Average 1.84

File-Backed

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 116,213 113,594 0.98
oltp_range_select 21,120 38,980 1.85
oltp_sum_range 20,546 38,062 1.85
oltp_order_range 3,720 6,635 1.78
oltp_distinct_range 4,719 7,601 1.61
oltp_index_scan 13,507 18,915 1.40
select_random_points 27,121 71,146 2.62
select_random_ranges 12,348 16,565 1.34
covering_index_scan 12,572 8,433 0.67
groupby_scan 35,821 55,366 1.55
index_join 10,783 18,044 1.67
index_join_scan 4,569 13,291 2.91
types_table_scan 1,290,661 2,968,289 2.30
table_scan 1,510,758 3,807,370 2.52
oltp_read_only 241,818 302,489 1.25
Average 1.75

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 190,889 258,071 1.35
oltp_insert 21,519 38,213 1.78
oltp_update_index 129,539 239,399 1.85
oltp_update_non_index 79,296 157,544 1.99
oltp_delete_insert 90,356 166,400 1.84
oltp_write_only 55,905 110,614 1.98
types_delete_insert 49,534 90,217 1.82
oltp_read_write 117,138 279,657 2.39
Average 1.87

File-Backed (autocommit)

Each statement runs as its own transaction — exposes per-commit
fixed costs that the wrapped-in-BEGIN/COMMIT tests amortize away.
SQLite uses WAL mode with synchronous=FULL in this section so
the comparison uses SQLite's durable WAL autocommit path.

Reads

Reads have no commit cost; these are the same SQL files as the
File-Backed Reads section, included here for symmetry and to
catch any per-statement overhead doltlite pays on the read path.

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 61,171 112,812 1.84
oltp_range_select 15,671 38,888 2.48
oltp_sum_range 14,966 38,029 2.54
oltp_order_range 3,282 6,619 2.02
oltp_distinct_range 4,252 7,531 1.77
oltp_index_scan 8,193 18,539 2.26
select_random_points 21,251 70,514 3.32
select_random_ranges 6,791 16,417 2.42
covering_index_scan 7,128 8,325 1.17
groupby_scan 35,368 55,173 1.56
index_join 8,027 17,858 2.22
index_join_scan 4,071 13,138 3.23
types_table_scan 1,280,327 2,963,818 2.31
table_scan 1,507,775 3,801,532 2.52
oltp_read_only 164,217 302,535 1.84
Average 2.23

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert_ac 16,097 60,014 3.73
oltp_insert_ac 18,669 76,200 4.08
oltp_update_index_ac 19,223 91,581 4.76
oltp_update_non_index_ac 16,723 68,794 4.11
oltp_delete_insert_ac 16,832 77,912 4.63
oltp_write_only_ac 17,604 77,739 4.42
types_delete_insert_ac 15,602 68,176 4.37
oltp_read_write_ac 24,048 97,730 4.06
Average 4.27

100000 rows, median of 5 invocations per test, workload-only timing via host monotonic clock when available.

Performance Ceiling Check (6x individual, 5x average)

All tests within ceilings.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Sysbench-Style Benchmark (composite PK): Doltlite vs SQLite

Companion to the classic Sysbench-Style Benchmark. Every workload here
runs against tables with a 2-column INTEGER PRIMARY KEY(a, b) WITHOUT ROWID.

Individual ratios gated at 6×; section averages gated at 5×.

In-Memory

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 30,830 46,061 1.49
oltp_range_select 17,704 26,157 1.48
oltp_sum_range 17,057 25,697 1.51
oltp_order_range 3,339 4,397 1.32
oltp_distinct_range 4,539 5,538 1.22
oltp_index_scan 4,506 7,409 1.64
select_random_points 27,620 39,273 1.42
select_random_ranges 7,697 9,986 1.30
covering_index_scan 4,113 4,870 1.18
groupby_scan 37,131 43,251 1.16
index_join 7,928 12,695 1.60
index_join_scan 4,040 6,882 1.70
types_table_scan 1,054,131 1,366,496 1.30
table_scan 1,255,735 1,671,392 1.33
oltp_read_only 145,308 198,894 1.37
Average 1.40

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 238,421 327,324 1.37
oltp_insert 18,713 33,260 1.78
oltp_update_index 64,446 124,076 1.93
oltp_update_non_index 50,734 85,678 1.69
oltp_delete_insert 48,074 96,813 2.01
oltp_write_only 26,078 56,884 2.18
types_delete_insert 23,331 44,907 1.92
oltp_read_write 96,875 169,830 1.75
Average 1.83

File-Backed

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 106,801 117,419 1.10
oltp_range_select 27,699 50,504 1.82
oltp_sum_range 27,295 48,793 1.79
oltp_order_range 4,436 7,711 1.74
oltp_distinct_range 5,595 8,856 1.58
oltp_index_scan 12,539 19,109 1.52
select_random_points 43,532 89,995 2.07
select_random_ranges 15,447 20,418 1.32
covering_index_scan 10,968 9,926 0.90
groupby_scan 39,437 61,010 1.55
index_join 12,704 25,691 2.02
index_join_scan 5,164 16,142 3.13
types_table_scan 1,201,268 2,646,475 2.20
table_scan 1,415,337 3,693,635 2.61
oltp_read_only 269,347 363,557 1.35
Average 1.78

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 248,798 352,040 1.41
oltp_insert 25,828 49,794 1.93
oltp_update_index 153,982 264,771 1.72
oltp_update_non_index 103,210 173,329 1.68
oltp_delete_insert 110,171 184,748 1.68
oltp_write_only 65,197 121,422 1.86
types_delete_insert 49,186 84,691 1.72
oltp_read_write 164,119 324,481 1.98
Average 1.75

File-Backed (autocommit)

Each statement runs as its own transaction — exposes per-commit
fixed costs that the wrapped-in-BEGIN/COMMIT tests amortize away.
SQLite uses WAL mode with synchronous=FULL in this section so
the comparison uses SQLite's durable WAL autocommit path.

Reads

Reads have no commit cost; these are the same SQL files as the
File-Backed Reads section, included here for symmetry and to
catch any per-statement overhead doltlite pays on the read path.

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 62,790 116,019 1.85
oltp_range_select 23,038 49,898 2.17
oltp_sum_range 22,358 49,441 2.21
oltp_order_range 4,204 7,779 1.85
oltp_distinct_range 5,231 8,976 1.72
oltp_index_scan 8,096 19,211 2.37
select_random_points 37,893 89,727 2.37
select_random_ranges 10,962 20,581 1.88
covering_index_scan 6,540 9,985 1.53
groupby_scan 39,376 60,975 1.55
index_join 10,422 25,796 2.48
index_join_scan 4,577 16,118 3.52
types_table_scan 1,193,408 2,639,971 2.21
table_scan 1,421,314 3,658,953 2.57
oltp_read_only 200,154 359,511 1.80
Average 2.14

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert_ac 23,040 77,683 3.37
oltp_insert_ac 26,660 98,933 3.71
oltp_update_index_ac 29,621 109,281 3.69
oltp_update_non_index_ac 23,247 92,592 3.98
oltp_delete_insert_ac 25,803 101,362 3.93
oltp_write_only_ac 27,322 107,908 3.95
types_delete_insert_ac 21,368 86,745 4.06
oltp_read_write_ac 32,911 126,640 3.85
Average 3.82

100000 rows, median of 5 invocations per test, workload-only timing via host monotonic clock when available.

Performance Ceiling Check (6x individual, 5x average)

All tests within ceilings.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Sysbench-Style Benchmark (BLOB PK): Doltlite vs SQLite

Companion to the classic Sysbench-Style Benchmark. Every workload here
runs against tables with a 16-byte big-endian BLOB PRIMARY KEY.

Individual ratios gated at 6×; section averages gated at 5×.

In-Memory

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 31,299 45,353 1.45
oltp_range_select 13,101 20,158 1.54
oltp_sum_range 12,296 19,655 1.60
oltp_order_range 2,972 3,928 1.32
oltp_distinct_range 4,036 4,992 1.24
oltp_index_scan 4,679 7,851 1.68
select_random_points 19,141 28,564 1.49
select_random_ranges 4,378 6,515 1.49
covering_index_scan 4,265 5,303 1.24
groupby_scan 32,872 39,220 1.19
index_join 7,154 12,684 1.77
index_join_scan 4,384 7,939 1.81
types_table_scan 1,077,739 1,395,756 1.30
table_scan 1,358,870 1,750,663 1.29
oltp_read_only 120,890 170,585 1.41
Average 1.45

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 241,340 339,169 1.41
oltp_insert 19,887 38,432 1.93
oltp_update_index 72,831 146,970 2.02
oltp_update_non_index 50,103 90,747 1.81
oltp_delete_insert 51,223 108,521 2.12
oltp_write_only 28,751 64,054 2.23
types_delete_insert 24,200 45,919 1.90
oltp_read_write 86,492 163,210 1.89
Average 1.91

File-Backed

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 105,598 120,712 1.14
oltp_range_select 21,610 49,546 2.29
oltp_sum_range 21,072 48,437 2.30
oltp_order_range 3,811 8,020 2.10
oltp_distinct_range 4,971 8,915 1.79
oltp_index_scan 12,817 21,343 1.67
select_random_points 34,047 83,969 2.47
select_random_ranges 11,412 17,398 1.52
covering_index_scan 11,566 13,037 1.13
groupby_scan 35,153 62,071 1.77
index_join 13,098 33,061 2.52
index_join_scan 6,596 23,253 3.53
types_table_scan 1,205,290 2,702,089 2.24
table_scan 1,489,572 4,259,064 2.86
oltp_read_only 236,916 361,249 1.52
Average 2.06

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 252,316 365,307 1.45
oltp_insert 41,068 59,113 1.44
oltp_update_index 175,026 316,310 1.81
oltp_update_non_index 113,239 193,949 1.71
oltp_delete_insert 120,341 220,248 1.83
oltp_write_only 78,748 137,078 1.74
types_delete_insert 50,619 87,898 1.74
oltp_read_write 164,369 349,898 2.13
Average 1.73

File-Backed (autocommit)

Each statement runs as its own transaction — exposes per-commit
fixed costs that the wrapped-in-BEGIN/COMMIT tests amortize away.
SQLite uses WAL mode with synchronous=FULL in this section so
the comparison uses SQLite's durable WAL autocommit path.

Reads

Reads have no commit cost; these are the same SQL files as the
File-Backed Reads section, included here for symmetry and to
catch any per-statement overhead doltlite pays on the read path.

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 61,405 122,429 1.99
oltp_range_select 17,642 49,819 2.82
oltp_sum_range 17,146 49,352 2.88
oltp_order_range 3,584 8,005 2.23
oltp_distinct_range 4,566 8,987 1.97
oltp_index_scan 8,677 21,633 2.49
select_random_points 29,179 83,774 2.87
select_random_ranges 7,228 17,592 2.43
covering_index_scan 7,786 13,134 1.69
groupby_scan 35,784 62,044 1.73
index_join 11,616 33,797 2.91
index_join_scan 6,493 23,589 3.63
types_table_scan 1,212,623 2,733,000 2.25
table_scan 1,523,623 4,335,935 2.85
oltp_read_only 176,834 369,527 2.09
Average 2.46

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert_ac 30,876 105,259 3.41
oltp_insert_ac 33,272 126,002 3.79
oltp_update_index_ac 37,490 146,437 3.91
oltp_update_non_index_ac 31,716 116,167 3.66
oltp_delete_insert_ac 36,162 126,222 3.49
oltp_write_only_ac 31,983 126,449 3.95
types_delete_insert_ac 29,665 113,455 3.82
oltp_read_write_ac 41,946 146,106 3.48
Average 3.69

100000 rows, median of 5 invocations per test, workload-only timing via host monotonic clock when available.

Performance Ceiling Check (6x individual, 5x average)

All tests within ceilings.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Sysbench-Style Benchmark (TEXT PK): Doltlite vs SQLite

Companion to the classic Sysbench-Style Benchmark. Every workload here
runs against tables with a 32-char hex TEXT PRIMARY KEY (UUID-shaped).

Individual ratios gated at 6×; section averages gated at 5×.

In-Memory

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 30,075 43,297 1.44
oltp_range_select 13,768 19,569 1.42
oltp_sum_range 13,006 19,177 1.47
oltp_order_range 3,248 3,906 1.20
oltp_distinct_range 4,269 4,957 1.16
oltp_index_scan 4,484 7,427 1.66
select_random_points 18,479 27,621 1.49
select_random_ranges 4,037 6,515 1.61
covering_index_scan 4,711 5,313 1.13
groupby_scan 35,616 41,218 1.16
index_join 6,982 12,169 1.74
index_join_scan 4,695 7,836 1.67
types_table_scan 1,096,969 1,403,727 1.28
table_scan 1,424,978 1,861,869 1.31
oltp_read_only 122,972 164,095 1.33
Average 1.41

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 231,205 324,462 1.40
oltp_insert 22,193 39,164 1.76
oltp_update_index 74,368 148,722 2.00
oltp_update_non_index 50,983 94,609 1.86
oltp_delete_insert 53,559 111,667 2.08
oltp_write_only 30,641 65,849 2.15
types_delete_insert 25,032 45,419 1.81
oltp_read_write 90,783 163,046 1.80
Average 1.86

File-Backed

Reads

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 124,147 135,008 1.09
oltp_range_select 25,263 58,554 2.32
oltp_sum_range 24,044 57,410 2.39
oltp_order_range 4,261 8,880 2.08
oltp_distinct_range 5,221 9,790 1.88
oltp_index_scan 14,997 23,563 1.57
select_random_points 36,639 93,090 2.54
select_random_ranges 13,435 19,994 1.49
covering_index_scan 14,436 14,608 1.01
groupby_scan 38,320 71,366 1.86
index_join 15,826 38,450 2.43
index_join_scan 8,818 28,045 3.18
types_table_scan 1,283,957 3,034,799 2.36
table_scan 1,736,202 5,275,674 3.04
oltp_read_only 267,358 405,988 1.52
Average 2.05

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert 244,519 360,112 1.47
oltp_insert 48,466 61,927 1.28
oltp_update_index 200,882 356,384 1.77
oltp_update_non_index 126,487 208,900 1.65
oltp_delete_insert 131,951 236,069 1.79
oltp_write_only 88,947 150,399 1.69
types_delete_insert 50,449 92,937 1.84
oltp_read_write 181,948 379,370 2.09
Average 1.70

File-Backed (autocommit)

Each statement runs as its own transaction — exposes per-commit
fixed costs that the wrapped-in-BEGIN/COMMIT tests amortize away.
SQLite uses WAL mode with synchronous=FULL in this section so
the comparison uses SQLite's durable WAL autocommit path.

Reads

Reads have no commit cost; these are the same SQL files as the
File-Backed Reads section, included here for symmetry and to
catch any per-statement overhead doltlite pays on the read path.

Test SQLite (us) Doltlite (us) Multiplier
oltp_point_select 68,768 133,781 1.95
oltp_range_select 19,280 58,376 3.03
oltp_sum_range 18,594 57,709 3.10
oltp_order_range 3,714 8,784 2.37
oltp_distinct_range 4,763 9,730 2.04
oltp_index_scan 9,751 23,461 2.41
select_random_points 30,666 93,057 3.03
select_random_ranges 8,122 19,943 2.46
covering_index_scan 9,174 14,614 1.59
groupby_scan 38,002 71,322 1.88
index_join 13,247 37,535 2.83
index_join_scan 8,289 28,334 3.42
types_table_scan 1,286,993 2,967,381 2.31
table_scan 1,699,624 5,247,919 3.09
oltp_read_only 187,123 401,352 2.14
Average 2.51

Writes

Test SQLite (us) Doltlite (us) Multiplier
oltp_bulk_insert_ac 18,954 59,128 3.12
oltp_insert_ac 18,293 75,203 4.11
oltp_update_index_ac 23,187 96,386 4.16
oltp_update_non_index_ac 16,742 75,189 4.49
oltp_delete_insert_ac 20,117 85,494 4.25
oltp_write_only_ac 20,547 85,080 4.14
types_delete_insert_ac 15,742 68,106 4.33
oltp_read_write_ac 26,321 108,140 4.11
Average 4.09

100000 rows, median of 5 invocations per test, workload-only timing via host monotonic clock when available.

Performance Ceiling Check (6x individual, 5x average)

All tests within ceilings.

Tim and others added 3 commits May 22, 2026 12:55
For tables created as INT PRIMARY KEY (or any single-column PK that
isn't INTEGER), SQLite/doltlite represent the PK as a pseudo-INDEX
exposed in pTab->pIndex with idxType == SQLITE_IDXTYPE_PRIMARYKEY.
That pseudo-index's tnum equals the table's own root.

After the merge-index-cleanup PR, pass1 was collecting it into
aIdxInfo and the inline merge built empty-valued entries for it.
The end-of-pass patch then overwrote the table's root with the
empty-valued index root — rows merged in from the other branch
showed NULL non-PK columns post-merge.

Detected by vc_oracle_at_test.sh's at_head_after_merge:
  CREATE TABLE t(id INT PRIMARY KEY, v INT);
  INSERT (1,10),(2,20); commit; branch feat; INSERT (3,30); commit;
  checkout main; INSERT (4,40); commit; merge feat;
  → row 3 came back as (3, NULL) instead of (3, 30).

Fix: skip primary-key pseudo-indexes when populating aIdxInfo so
they aren't treated as secondary indexes by the inline merge.

Also resolves a leftover stash conflict in src/chunk_store.h that
predated this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same WITHOUT ROWID pseudo-index trap as the merge-path fix in the
previous commit, in the conflict-resolve path. doltliteApplyRawRowMutation
iterates pTab->pIndex to update secondary indexes after the table
mutation, but for INT-PRIMARY-KEY (auto-converted to WITHOUT ROWID)
tables the PK appears as a pseudo-INDEX with tnum == table's root.
Treating it as a secondary index built an empty-valued entry and
overwrote the table tree, dropping the non-PK column values.

Detected by vc_oracle_conflicts_resolve_test.sh: 10 --theirs cases
failed with NULL where theirs's value should be (e.g.
resolve_theirs_single_table got 'R|1|' instead of 'R|1|200').

Fix: skip pIdx->idxType == SQLITE_IDXTYPE_PRIMARYKEY in the
applyRawRowMutation index-maintenance loop, mirroring the merge
fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For WITHOUT ROWID tables, the row record stored in the table tree
contains only the non-PK columns — the PK columns live in the b-tree
key. doltliteBuildIndexSortKey was building the index key from the
row record alone, which dropped the PK entirely; two rows with the
same indexed-column value but different PKs collided on the resulting
key and one overwrote the other in mi->pEdits.

This silenced UNIQUE-violation detection because the duplicate that
should have surfaced never made it into the index map (two inserts
with identical key in a MutMap is a replace, not a collision).

Detected by vc_oracle_fk_merge_test.sh's without_rowid_unique_merge_errors:

  CREATE TABLE t(pk TEXT PRIMARY KEY, v1 INT UNIQUE);
  INSERT ('base',10); commit; branch feat;
  feat: INSERT ('feat',20); commit;
  main: INSERT ('main',20); commit; merge feat;

  → Expected: 'constraint violations | rolled back'.
  → Got:      success (silently corrupt UNIQUE index).

Fix: extend doltliteBuildIndexSortKey to accept (pTreeKey, nTreeKey).
For WITHOUT ROWID tables (iPKey < 0), append the table b-tree key
bytes — which are already a sortkey of the PK columns — to the
sortkey output. Matches the native iv format captured from the
SQL INSERT path:

  iv key = sortkey(v1) + sortkey(pk)

Six callers in doltlite_merge.c and one in prolly_btree.c
(mutateSecondaryIndex) updated to pass pChange->pKey / pKey through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@timsehn timsehn merged commit b6fc152 into master May 22, 2026
12 checks passed
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