From 579cfd8752652076cfc98d0fc08f150fbd32b6db Mon Sep 17 00:00:00 2001
From: Eric Coissac
Date: Fri, 28 Aug 2026 14:14:22 +0200
Subject: [PATCH] docs: document partition runner deadlock fix and select CLI
updates
Update documentation to reflect architectural fixes in the NUMA partition runner and implementation changes in the merge and select modules. The partition runner now wraps task execution in `catch_unwind`, emitting panic events that count toward completion thresholds to prevent controller deadlocks, while re-raising the first payload on return. CLI documentation for select has been updated to mandate output directories, introduce default hard-linking with automatic fallback, and document presence aggregation optimizations. Known issues such as self-merge crashes and sparse-packed index gaps are now documented as failing fast due to these improvements.
---
.../numa_partition_runner/index.html | 40 ++++-
DevDoc/implementation/merge/index.html | 30 ++++
DevDoc/implementation/select/index.html | 144 +++++++++++-------
.../architecture/numa_partition_runner.md | 21 ++-
DevDocMD/implementation/merge.md | 11 ++
DevDocMD/implementation/select.md | 90 ++++++-----
6 files changed, 243 insertions(+), 93 deletions(-)
diff --git a/DevDoc/architecture/numa_partition_runner/index.html b/DevDoc/architecture/numa_partition_runner/index.html
index c25fea11..c79dca08 100644
--- a/DevDoc/architecture/numa_partition_runner/index.html
+++ b/DevDoc/architecture/numa_partition_runner/index.html
@@ -1367,6 +1367,17 @@
+
+
+
@@ -1836,11 +1858,27 @@ dormant workers have been woken and grows every node by the same amount per
step, capped by that node's remaining dormant workers and by the run's total
budget (n_total) — balance across nodes is now guaranteed by construction,
not incidental to channel implementation details.
+
Panic propagation (fixed, 2026-08-28)
+
A worker whose f(i) panicked used to never send its WorkerEvent::Completed
+— the controller's while completed < n_total loop then waited forever for
+an event that partition could no longer produce, since other live
+workers/timer threads kept event_rx open. Discovered via obikselect
+panicking on an unimplemented Sparse matrix case (see implementation/select.md):
+the process hung indefinitely instead of erroring out.
+
Fixed: each worker wraps f(i) in std::panic::catch_unwind and sends a new
+WorkerEvent::Panicked(i, payload) instead of silently dropping the
+partition. The controller counts it toward completed (unblocking the loop)
+and keeps the first payload seen; once run returns, panic::resume_unwind
+re-raises it on the caller's thread — the original message/backtrace still
+surfaces, from the right place, instead of a silent deadlock. Takes priority
+over a plain Err (a panic means a bug, not a normal typed failure).
Open questions
Error handling: run currently returns the first error; remaining errors
- are dropped. A Vec<E> return would give complete diagnostics.
+ are dropped. A Vec<E> return would give complete diagnostics. (Panics are
+ now caught and re-raised individually — see above — this only concerns
+ plain Err results.)
INITIAL_DIVISOR / GROWTH_DIVISOR tuning: currently 4 and 8
diff --git a/DevDoc/implementation/merge/index.html b/DevDoc/implementation/merge/index.html
index 7cfe4472..9f91a463 100644
--- a/DevDoc/implementation/merge/index.html
+++ b/DevDoc/implementation/merge/index.html
@@ -1034,6 +1034,17 @@
+
@@ -1795,6 +1817,14 @@ spectrums/
<label>.json ← one file per genome, rebuilt from all sources
index.meta ← complete genome list + evidence kind written at bootstrap
+
mphf.bin/unitigs.bin/evidence.bin/unitigs.bin.idx/fingerprint.bin/layer_meta.json marked "unchanged" above are hard-linked from the base source's own files during the bootstrap copy (2026-08-28), not copied — merge_partition never rewrites them for pre-existing layers, only the presence/counts subdirectory gets widened in place, so only that subdirectory is a real, independent copy. Falls back to a real copy per file if linking itself fails (different filesystems). Verified: source files are byte-identical (checksummed) before/after a normal merge.
+
Known issue (not yet fixed, 2026-08-28)
+
Merging an index against itself — literally the same directory passed twice as separate source arguments (e.g. obikmer merge -o out IDX IDX --rename-duplicates) — panics deep in the MPHF's rank-select structure (common_traits::select_in_word, assertion failed: rank < self.count_ones()), inside MphfLayer::find called from merge_partition's "is this source kmer already in dst" check against the bootstrap-copied dst_layers. Root cause not identified; ruled out so far:
+
+
Not an empty-new-layer issue: a source contributing zero new kmers (verified both as the sole additional source and as a third, fully-redundant source in a 3-way merge) is handled correctly — no layer is created for it, no crash, exit 0.
+
Not caused by the hard-link change above: verified via checksum that a normal (two distinct sources) merge leaves every source file byte-identical.
+
+
Only reproduces when sources[0] and sources[1] are the exact same on-disk path opened as two independent KmerIndex handles — an artificial scenario (nobody merges an index with itself intentionally), deprioritized rather than investigated further for now. Does terminate cleanly rather than hang, since PartitionRunner's panic propagation fix (see architecture/numa_partition_runner.md) now surfaces it as a normal process panic/exit 101 instead of a silent deadlock.
Exactly one of --output or --in-place must be specified.
-
--output <dir> — writes a new index to <dir>. The source index is
-unchanged. The MPHF and unitig files are copied; only the data matrices are
-rewritten with the new column layout.
-
--in-place — rewrites the data matrices of the source index directly.
-Removed or replaced columns are lost. The operation writes to temporary files
-first, then renames atomically, so an interrupted run leaves the index intact.
+
--output <dir> is required — select always writes a new index; there is no
+--in-place mode (2026-08-28: never implemented, removed from the design). The
+source index is unchanged.
+
Each layer's kmer-identity files (mphf.bin/unitigs.bin/evidence.bin/
+unitigs.bin.idx/fingerprint.bin/layer_meta.json) are never rewritten by a
+column projection/aggregation, so they are hard-linked into the output rather
+than copied — no extra disk for them even on a large index. Falls back to a
+real copy automatically if linking fails (different filesystems); --force-copy
+forces a real copy always, for an output that must survive independently of the
+source on disk (a hard link shares the same inode — rewriting one path outside
+select itself would affect the other). Only the presence/counts
+subdirectory is ever a genuinely new, independent file.
+
To replace an index with a selected version of itself, select to a temporary
+directory and swap it in (rm -rf INDEX && mv INDEX.tmp INDEX) — the case
+--in-place used to cover.
# Step 1: keep only B. nana-specific k-mers
obikmerfiltermyindex--outputfiltered\
@@ -1921,32 +1939,46 @@ obikmerselect
Implementation notes
-
select does not rebuild the MPHF. The 256 partitions are processed in parallel
-(rayon), each writing its output independently; results require no synchronisation
-because every partition owns a distinct set of files.
-
For each layer in each partition:
+
select does not rebuild the MPHF. Every partition is processed independently
+(PartitionRunner), each writing its own output layers; no cross-partition
+synchronisation is needed.
+
For each layer in each partition (obikselect::select_layer::select_partition):
-
The slot count n is read by opening the source data matrix.
-
A new data matrix is built with M columns (M = number of output columns).
-
For each slot s in 0..n:
-
old_row = matrix.fill_row(s) — reads the original N-column row without allocating.
Pass-through columns are represented as single-element groups with the
- default operator (any for presence, sum for count) — same code path.
-
-
-
The new row is written slot by slot into each column builder.
-
All plain files in the source layer directory (mphf.bin, unitigs.bin,
- evidence files, layer_meta.json) are copied verbatim; only the presence/
- or counts/ subdirectory is rewritten.
+
copy_layer_files hard-links the source layer's kmer-identity files
+ (mphf.bin/unitigs.bin/evidence.bin/unitigs.bin.idx/
+ fingerprint.bin/layer_meta.json) into the destination — never a real
+ copy unless linking fails or --force-copy is given.
+
A new data matrix is built with M columns (M = number of output columns),
+ under a fresh presence//counts/ subdirectory (never touching the
+ source's own).
+
Presence source (2026-08-28: batch_presence_counts): one shared pass
+ over the source bit matrix computes every output group's presence count at
+ once — row-major native for a Sparse source (for_each_genome_in_row,
+ which has no column representation to read a col_view from at all — the
+ reason this replaced the old per-group loop, not just an optimisation of
+ it), deduplicated column-major (one col_view per distinct referenced
+ column, not per group) for Columnar/Packed. Every AggOp for a bit
+ matrix is then a cheap derivation of that one count vector (sum = the
+ count itself, any/max = count ≥ 1, all/min = count == group
+ size, none = count == 0) — see
+ obikselect::select_layer::agg_result_from_count.
+
Count source: unchanged, one col_view-driven pass per output column
+ via MatrixGroupOps — sum/min/max are genuine per-value reductions
+ for a count matrix, not derivable from a single presence count the way
+ they are for a bit matrix.
index.meta is rewritten with the new genome list and updated with_counts.
-
--in-place write strategy: new data is written to a temporary sibling
-directory (presence_new/ or counts_new/); on success the old directory is
-removed and the temporary one is renamed into place. An interrupted run leaves
-at most one stale *_new/ directory; the original data is intact until the
-rename step.
+
Known gap (not yet fixed, 2026-08-28)
+
Step 4 above still panics (col_view() not available on Sparse
+PersistentCompactIntMatrix) if the source is a count index packed
+sparse — batch_presence_counts' row-major treatment was only ported to the
+bit-matrix (Presence) case, since that was the one actually blocking a real
+benchmark run. select/filter on a sparse-packed count index still hits
+this; the fix would follow the same shape (a PersistentSparseCompactIntMatrix
+row-major decode, analogous to for_each_genome_in_row), just not done. Since
+obisys::numa::runner::PartitionRunner's panic-propagation fix (see
+architecture/numa_partition_runner.md), this at least fails fast (process
+panic, exit 101) instead of hanging.
diff --git a/DevDocMD/architecture/numa_partition_runner.md b/DevDocMD/architecture/numa_partition_runner.md
index 06b7765c..994e8c76 100644
--- a/DevDocMD/architecture/numa_partition_runner.md
+++ b/DevDocMD/architecture/numa_partition_runner.md
@@ -304,10 +304,29 @@ step, capped by that node's remaining dormant workers and by the run's total
budget (`n_total`) — balance across nodes is now guaranteed by construction,
not incidental to channel implementation details.
+## Panic propagation (fixed, 2026-08-28)
+
+A worker whose `f(i)` panicked used to never send its `WorkerEvent::Completed`
+— the controller's `while completed < n_total` loop then waited forever for
+an event that partition could no longer produce, since other live
+workers/timer threads kept `event_rx` open. Discovered via `obikselect`
+panicking on an unimplemented `Sparse` matrix case (see `implementation/select.md`):
+the process hung indefinitely instead of erroring out.
+
+Fixed: each worker wraps `f(i)` in `std::panic::catch_unwind` and sends a new
+`WorkerEvent::Panicked(i, payload)` instead of silently dropping the
+partition. The controller counts it toward `completed` (unblocking the loop)
+and keeps the first payload seen; once `run` returns, `panic::resume_unwind`
+re-raises it on the caller's thread — the original message/backtrace still
+surfaces, from the right place, instead of a silent deadlock. Takes priority
+over a plain `Err` (a panic means a bug, not a normal typed failure).
+
## Open questions
- **Error handling**: `run` currently returns the first error; remaining errors
- are dropped. A `Vec` return would give complete diagnostics.
+ are dropped. A `Vec` return would give complete diagnostics. (Panics are
+ now caught and re-raised individually — see above — this only concerns
+ plain `Err` results.)
- **`INITIAL_DIVISOR` / `GROWTH_DIVISOR` tuning**: currently `4` and `8`
(start at 1/4 of a node's cores, grow by 1/8 per step), chosen to fix an
diff --git a/DevDocMD/implementation/merge.md b/DevDocMD/implementation/merge.md
index cd273495..b93332d6 100644
--- a/DevDocMD/implementation/merge.md
+++ b/DevDocMD/implementation/merge.md
@@ -194,3 +194,14 @@ spectrums/