SDKRelease

When the same string ID meant two different things

1.0.11 fixes GroupBy and Pivot giving wrong answers on DFC files with more than one segment, where each segment numbered its strings from 1 and the reader looked every ID up in the first segment's table.

Version 1.0.11 is on NuGet, one day after 1.0.10. There is one thing in it, and it is not a speedup: GroupBy and Pivot were returning wrong answers on DFC files that span more than one segment.

If you group over a .dfc file that you have appended to over time, please take this one.

What it looked like

Three things went wrong, and each one looked like its own bug. Group counts came back too high, so a column with ten distinct values could report sixteen groups. Strings turned up in the wrong place, so a query grouped by country could hand you a product ID. And rows from later in the file were counted into the wrong category.

That last part is what bothers me. A wrong total from a grouping query is the worst kind of bug to ship, because nothing throws. The result has the right shape, the right column names, and numbers that look believable.

What was actually wrong

String columns in DFC are dictionary encoded. The column stores integer IDs, and the file carries a table that maps each ID back to its string. IDs start at 1, and 0 is reserved for null. It is a cheap trade: a fixed-width uint32 per row instead of a length and a pile of bytes per row.

The catch is that the table is per segment. A DFC file is a sequence of segments, each written independently, each numbering its own strings from 1. Segment 0 might map 1 to "Apple" and 2 to "Banana". Segment 1, written later from different data, maps 1 to "Cherry" and 2 to "Date".

GroupBy and Pivot read the ID column across the whole file and looked every ID up in the first segment's table. So segment 1's "Cherry" rows came back as "Apple".

Local IDs, resolved through segment 0 Segment 0 table 1 = Apple 2 = Banana Segment 1 table 1 = Cherry 2 = Date GroupBy reads 2 groups, wrong Cherry arrives as Apple. One global table (seg 0, 1) = 1 (seg 0, 2) = 2 (seg 1, 1) = 3 (seg 1, 2) = 4 GroupBy reads 4 groups, correct
The same file read two ways: every ID through segment 0, then each (segment, local ID) pair mapped into one shared numbering.

Why nobody caught it sooner

A single-segment file cannot hit this. One table, so a local ID is already the right ID. Most files written in one SaveAsync call are one segment, and every test that wrote its data in one pass passed.

You get segments when the data goes past the configured segment size, and when repeated AppendAsync calls roll a new one. Both of those mean a file that has grown over time, which is exactly the file where a wrong total is least likely to catch anyone's eye.

The fix

Reads that cross segments now go through one global string table that covers the whole file, built the first time it is needed and cached after that. It walks each segment's table in turn, removes duplicate values, and maps (segment, local ID) to a single global ID. Two segments holding "Apple" share an ID. Two segments using local ID 1 for different strings do not.

The segmented reader picked up GetGlobalStringId and ResolveGlobalStringId, and the grouping and pivot paths now pass the segment index along with the ID. Single-segment files short-circuit the whole thing: the global ID is the local ID, and no table gets built.

Before 1.0.11 1.0.11
Groups for a 10-value column up to 16 10
Value shown for a later segment the first segment's string at that ID its own string
Single-segment files correct correct, and unaffected

Checking it yourself

The shortest way to reproduce it is to force two segments out of four rows by setting the segment size to one byte, so each append rolls a new one. Both segments then use local IDs 1 and 2 for different strings.

var options = new VelocityOptions
{
    TargetSegmentSizeBytes = 1,
    MaxSegmentSizeBytes = 1,
    AutoCompactionEnabled = false
};

var first = new DataBlock();
first.AddColumn(new DataColumn("Category", typeof(string)));
first.AddColumn(new DataColumn("Value", typeof(int)));
first.AddRow(new object[] { "Apple", 1 });
first.AddRow(new object[] { "Banana", 2 });

var second = new DataBlock();
second.AddColumn(new DataColumn("Category", typeof(string)));
second.AddColumn(new DataColumn("Value", typeof(int)));
second.AddRow(new object[] { "Cherry", 3 });
second.AddRow(new object[] { "Date", 4 });

var block = await VelocityDataBlock.SaveAsync("categories.dfc", first, options);
await block.AppendAsync(second);
block.Dispose();

using var reopened = new VelocityDataBlock("categories.dfc", options);

DataBlock groups = reopened
    .GroupByAggregate("Category", "Value", AggregationType.Sum)
    .Execute();

Console.WriteLine(groups.RowCount);   // 4, one per category

Four rows, four categories, and a sum_Value of 1, 2, 3 and 4. Before the fix this came back as two groups. Pivot checks the same way: pivot on a string column whose values differ between segments, and every distinct value should get a column of its own.

What I take from it

Dictionary encoding is still a good trade. Swapping a repeated string for a uint32 takes a string column from roughly twelve bytes per row down to a little over four, and it lets an equality filter compare integers instead of characters.

The part that bit me is that an ID only means something next to the table that handed it out. While the file is one piece, you never notice. Split it into pieces that are written separately and the ID is only half of an identity, so every read that crosses a boundary has to carry the other half with it.

Getting it

dotnet add package Datafication.Storage.Velocity

If you run GroupBy or Pivot over a .dfc file built from more than one append, this is worth updating for today. The details are in the changelog, and the query work that shipped the day before is in faster Velocity queries, and one API removed.