Skip to content

Feature merge - #127

Open
alowrydi wants to merge 6 commits into
mainfrom
feature-merge
Open

Feature merge#127
alowrydi wants to merge 6 commits into
mainfrom
feature-merge

Conversation

@alowrydi

Copy link
Copy Markdown
Contributor

On-disk partition-segment merge module, di.merge

Extracts TorQ's code/common/merge.q into a standalone kdb-x module - on-disk partition-segment
merging for the write-down (WDB) flow, called by both wdb.q and tickerlogreplay.q in legacy
TorQ: whole-partition, column-by-column, or a size-driven hybrid of the two, chosen per partition
by a configurable row-count or byte-size limit, plus partition-size tracking to drive that
decision. The last unresolved hard dependency for di.wdb.

Trello ticket - https://trello.com/c/HAJdcl9V/111-kdb-x-merge


Files created

File Description
di/merge/init.q Loads merge.q, defines export of 14 functions
di/merge/merge.q Full implementation - module state, internal helpers, public API, init
di/merge/merge.md Full module documentation - see below
di/merge/test.csv 190 k4unit test assertions
di/merge/VERSION Module version

How to test

k4unit:use`di.k4unit
k4unit.moduletest`di.merge

2026.08.21T14:46:17.418 start
2026.08.21T14:46:17.418 :.../di/merge/test.csv 190 test(s)
2026.08.21T14:46:17.456 end
Test results:
...
All tests passed

190/190 assertions passing.

Coverage includes: dependency validation, the requireinit guard on every exported function,
config application (row-count vs byte-size batching, partlimit splitting), partition-size
tracking and cross-process sync, both re-init and failure-isolation design decisions with
regression coverage, version/getapimeta shape, and end-to-end mergebypart/mergebycol/
mergehybrid against real on-disk segments.


Design decisions

1. No hard module dependencies; parted columns supplied by the caller - Legacy merge.q read a
table's parted column(s) from .sort.params (populated from sort.csv) via
getextrapartitiontype. That coupling was removed on extraction: the parted column(s)
(extrapartitiontype) are passed in by the caller instead, so di.merge doesn't depend on
di.sort - confirmed against the plan's own "Hard dependency tree", which lists di.merge under
STANDALONE. Only log is injected, via init.

2. syncpartsizes - a real receive-side API for a legacy raw-IPC pattern - Legacy wdb.q fans
partition-size state out to sort-worker processes over raw async IPC with no symmetric
receive-side function: a receiving process evaluated the raw (upsert;.merge.partsizes;y)tuple directly, which only worked if it had already loadedmerge.qso the table existed with the right schema - an undocumented, load-order-dependent contract.syncpartsizes[t]gives the receive side a real,requireinit`-guarded function to go through instead.

3. init preserves tracked-but-unmerged partition sizes across a re-init - Calling init again
with valid deps (e.g. a live config reload) does not wipe .z.m.partsizes; segments tracked since
the last clearpartsizes[] survive. partsizes is orthogonal to the log/mergebybytelimit/
partlimit deps a re-init is typically changing, and silently discarding tracked-but-unmerged
segment sizes is a worse failure mode than leaving them alone. init logs explicitly when it
preserves state, so the decision is visible rather than something a future debugger has to
discover by reading source.

4. mergebypart isolates each segment's read; mergebycol deliberately does not protect its
column read
- Not equivalent failure modes, so making them match wouldn't obviously be the safer
choice. mergebypart now reads each segment in a batch individually and protected - a
missing/corrupt segment is error-logged and dropped without disturbing its batch-mates, which
still merge. mergebycol merges one column at a time into the same destination; a swallowed
failure partway through would leave some columns reflecting the new data and others silently
stale - a genuinely worse, silently-inconsistent partition, not just a delayed merge. So
mergebycol's column read stays intentionally unprotected.

5. The parted attribute is applied once, to the whole destination, only via mergehybrid - A
full write-down-and-merge smoke test against real segments and real di.log (not mocks) found
that neither mergebypart nor mergebycol alone can guarantee dest ends up with the `p#
attribute genuinely set: upsert appends raw values onto an on-disk column without persisting an
in-memory attribute, and no single batch/column write can guarantee the whole destination stays
grouped once several have all appended to it. mergehybrid closes this with one final
read-resort-reattribute-rewrite pass over the complete destination, after every batch and column
has merged - a deliberate, documented departure from the module's memory-flat design goal for that
one step, since a destination that's silently never truly parted is worse. mergebypart/
mergebycol called standalone, bypassing mergehybrid, do not get this guarantee automatically -
documented in merge.md; di.wdb (in progress) will always route through mergehybrid for this
reason.

6. A real-logger smoke test, not just the k4unit mock suite - Mock loggers only confirm a
message was logged at the right level; they don't process message content, so they can't catch a
malformed message - a list where a flat string was expected, for instance. That gap is exactly
what a real-logger pass found: checkenumerabletype built its error message with string applied
directly to a symbol list rather than ", " sv string ..., producing a malformed nested value
that crashed the moment a non-enumerable parted column was checked - the one case the function
exists to catch. Fixed to match checkpartitiontype's already-correct pattern, with a regression
test that asserts on the message's structure (10h=type), not just that it fired.


Checklist

  • 190/190 k4unit test assertions passing
  • Follows consistency.md and style.md
  • Follows dependency injection guidelines
  • merge.md documents all exported functions, config, the requireinit guard, all design
    decisions, cross-process partition-size sync, and a usage example
  • No hard dependencies on other di.* modules - standalone

Documentation

See merge.md for full reference including the dependency contract, config keys, exported
function documentation, the requireinit guard, all six design decisions, cross-process
partition-size sync, and a usage example.

Olly99999 and others added 5 commits July 6, 2026 17:39
Rebuilds the scaffolding around the initial draft to match conventions that
landed since it was written (di.eodtime, di.depcheck, di.kafka, di.servers,
di.heartbeat), while keeping the original merge algorithm (getpartchunks,
mergebypart, mergebycol, mergehybrid) - a faithful, line-checked port of
TorQ's code/common/merge.q, verified against both real callers (wdb.q,
tickerlogreplay.q) - untouched.

Bug fix: mergebypart's failure handler double-converted an already-string
error message via a redundant string call, which corrupted the log message
and made the handler itself throw a second, uncaught error - so a merge
failure that should be logged and skipped instead crashed the caller. Fixed
and covered by a regression test. The bug predates this refactor (present in
the legacy TorQ source too) and was only surfaced by deliberately forcing an
upsert failure, since no happy-path test exercised that branch.

Two deliberate design decisions:
- init now preserves tracked-but-unmerged partition sizes across a re-init
  (e.g. a live config reload) instead of wiping them - partsizes is
  orthogonal to the deps a re-init typically changes, and silent data loss
  is worse than leaving it alone. init logs explicitly when it preserves
  existing tracked partitions.
- mergebycol's column read is intentionally left unprotected (unlike
  mergebypart's now-guarded upsert): a partial-column failure mid-merge
  would leave the destination silently inconsistent (some columns updated,
  others stale), which is worse than failing loudly.

Scaffolding changes:
- requireinit guard on every exported function except init/getapimeta
- normlog/kx.log auto-detection removed; init does strict binary-dict
  validation requiring info+error only (merge.q never calls warn)
- init validation inlined, no setdeps/setconfig split
- VERSION file added, version exported, read defensively
- deps.q removed - standalone module, no hard deps
- getapimeta added for di.api registration
- stale sort.csv wording removed from checkpartitiontype's log messages
- new: syncpartsizes, giving the receive side of the legacy partsizes IPC
  fan-out a real, guarded function instead of a raw upsert message
- state access normalised to .z.m throughout

148 k4unit assertions, including real on-disk-segment coverage for all three
merge paths, the requireinit guard, version/getapimeta shape, syncpartsizes,
the re-init preservation behaviour, and the mergebypart error-handler fix.
…etpartchunks drop logging

A senior review of the initial push flagged three gaps before this is PR-ready:
getfirstcharpartitions had no positive-path test (only the pre-init rejection case),
getpartchunks silently dropped untracked partitions with no trace, and merge.md's
"two callers" note had gone half-stale now that checkenumerabletype is covered.

Adds a positive-path test for getfirstcharpartitions, adds an info-level log line
(and test) when getpartchunks drops an untracked partition without changing its
filtering behaviour, and updates merge.md accordingly.
…ion, checkenumerabletype

A full write-down-and-merge smoke test (real segments, real di.log, both of TorQ's partbyenum
and partbyfirstchar write patterns) surfaced three real gaps beyond what the mock-logger k4unit
suite could catch:

checkenumerabletype built its error message with string[list] instead of ", " sv string list,
producing a malformed nested value that crashed under a real logger the moment a non-enumerable
parted column was checked - the one case the function exists to catch. Fixed to match
checkpartitiontype's already-correct pattern.

mergehybrid never actually left the destination `p#-attributed, even when every batch merged
cleanly: upsert appends raw values onto an on-disk column without persisting an in-memory
attribute, and no single batch/column write can guarantee the whole destination stays grouped
once multiple batches (and mergebycol's un-resorted, multi-value segments) have all appended to
it. Confirmed empirically both ways: a pure mergebypart merge left no attribute at all, and a
partbyfirstchar-style merge through mergebycol left the parted column genuinely unsorted on disk.
Fixed with a single, final resort-and-reattribute pass over the whole destination in mergehybrid,
after every batch and column has merged - a deliberate, documented departure from the module's
"keeps memory flat" goal for that one step, since a destination that's silently never truly
parted is worse.

mergebypart read every segment in a batch as one unprotected unit, so one missing/corrupt segment
file crashed uncaught and took its healthy batch-mates down with it. Now reads each segment
individually and protected - a bad segment is error-logged and dropped, the rest of the batch
still merges.

Adds regression coverage for all three (message-shape assertion, mergehybrid attribute/resort
test spanning both merge paths, mergebypart batch-isolation test) and documents the attribute
and batch-isolation behaviour in merge.md.
Comment thread di/merge/merge.q
Comment thread di/merge/merge.q
Comment thread di/merge/merge.q
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

1 critical | 2 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

…irs footgun

Wrap mergeonecol's segment-column read so a missing/unreadable column
names itself in the propagated error instead of a bare read failure,
without changing the deliberate fail-loud behaviour mergebycol relies
on. Rename mergehybrid's in-place partdirs reassignment to underlimit
so partdirs keeps meaning "everything requested" for future edits.
Add regression coverage for the missing-column path and for
mergehybrid's overlimit-empty / overlimit-equals-partdirs branches,
and document tablename's global-table requirement once for all four
functions that share it.
Comment thread di/merge/merge.q
@DI-Software-Engineering

Copy link
Copy Markdown

DIReview Summary

0 critical | 1 warning(s) | 0 suggestion(s)

⚠️ Spec check skipped — tracker lookup failed (NO_REF_FOUND). Standards axis only.

@ConorSwainDI ConorSwainDI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looks good to me

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.

4 participants