@@ -2704,6 +2704,16 @@ private bool HasExplicitNamedIndex(string column)
27042704 {
27052705 // Durable DELETE: physically mark the removed records so a reopen skips them.
27062706 TombstoneDeletedPositions ( positions ) ;
2707+
2708+ // Legacy variable-length (non-fixed-width) columnar tables keep older stale versions
2709+ // of a key in the file (UPDATE appends a new version). Tombstoning only the newest
2710+ // version would let an older stale version win the reopen index rebuild ("keep the
2711+ // latest position per key") and resurrect the deleted row — purge the remaining
2712+ // records that carry a deleted key too.
2713+ if ( StorageMode == StorageMode . Columnar && ! _fixedWidthRecords && PrimaryKeyIndex >= 0 )
2714+ {
2715+ TombstoneRemainingVersionsOfDeletedKeys ( recordsToDelete ) ;
2716+ }
27072717 }
27082718
27092719 TryAutoCompact ( ) ;
@@ -2712,6 +2722,171 @@ private bool HasExplicitNamedIndex(string column)
27122722 Interlocked . Add ( ref _cachedRowCount , - recordsToDelete . Count ) ;
27132723 }
27142724
2725+ /// <summary>
2726+ /// Legacy (variable-length) columnar tables append a new row version on every UPDATE, leaving
2727+ /// older stale versions of the same key in the data file. A durable DELETE tombstones only the
2728+ /// newest version; an older stale version would then win the reopen index rebuild and resurrect
2729+ /// the deleted row. This scans the file once and tombstones every remaining (non-tombstoned)
2730+ /// record whose primary key was just deleted. Fixed-width tables are unaffected (their UPDATEs
2731+ /// are in-place overwrites, so at most one live version per key exists).
2732+ /// </summary>
2733+ private void TombstoneRemainingVersionsOfDeletedKeys ( List < ( long storagePosition , Dictionary < string , object > row ) > recordsToDelete )
2734+ {
2735+ if ( this . storage is null || recordsToDelete . Count == 0 )
2736+ {
2737+ return ;
2738+ }
2739+
2740+ var pkCol = this . Columns [ this . PrimaryKeyIndex ] ;
2741+ var deletedKeys = new HashSet < string > ( StringComparer . Ordinal ) ;
2742+ foreach ( var ( _, row ) in recordsToDelete )
2743+ {
2744+ if ( row . TryGetValue ( pkCol , out var pkValue ) && pkValue != null )
2745+ {
2746+ deletedKeys . Add ( pkValue . ToString ( ) ?? string . Empty ) ;
2747+ }
2748+ }
2749+
2750+ if ( deletedKeys . Count == 0 || ! File . Exists ( DataFile ) )
2751+ {
2752+ return ;
2753+ }
2754+
2755+ List < long > ? remainingPositions = null ;
2756+ if ( ! this . storage . AreRecordsEncrypted ( DataFile ) )
2757+ {
2758+ remainingPositions = ScanLegacyPlaintextRemainingKeys ( DataFile , pkCol , deletedKeys ) ;
2759+ }
2760+
2761+ remainingPositions ??= ScanLegacyRemainingKeysViaStorage ( DataFile , pkCol , deletedKeys ) ;
2762+ if ( remainingPositions is { Count : > 0 } )
2763+ {
2764+ TombstoneDeletedPositions ( remainingPositions . ToArray ( ) ) ;
2765+ }
2766+ }
2767+
2768+ /// <summary>
2769+ /// Single buffered pass over a plaintext legacy data file: parse the length-prefixed record
2770+ /// stream inline (skipping tombstone markers) and decode only the PK column. Returns the
2771+ /// physical offsets of records whose PK is in <paramref name="deletedKeys"/>, or
2772+ /// <see langword="null"/> when the raw layout could not be parsed safely (the caller then falls
2773+ /// back to the storage-layer scan, which understands per-record encryption).
2774+ /// </summary>
2775+ private List < long > ? ScanLegacyPlaintextRemainingKeys ( string dataFile , string pkCol , HashSet < string > deletedKeys )
2776+ {
2777+ var matches = new List < long > ( ) ;
2778+ try
2779+ {
2780+ using var fs = new FileStream (
2781+ dataFile , FileMode . Open , FileAccess . Read ,
2782+ FileShare . ReadWrite | FileShare . Delete , 65536 , FileOptions . SequentialScan ) ;
2783+
2784+ Span < byte > lengthBuf = stackalloc byte [ 4 ] ;
2785+ long position = 0 ;
2786+ while ( fs . Position < fs . Length )
2787+ {
2788+ if ( fs . Read ( lengthBuf ) < 4 )
2789+ {
2790+ break ;
2791+ }
2792+
2793+ int length = System . Buffers . Binary . BinaryPrimitives . ReadInt32LittleEndian ( lengthBuf ) ;
2794+ if ( length < 0 )
2795+ {
2796+ int slotSize = - length ;
2797+ if ( slotSize < 4 || position + slotSize > fs . Length )
2798+ {
2799+ break ;
2800+ }
2801+
2802+ fs . Seek ( slotSize - 4 , SeekOrigin . Current ) ;
2803+ position += slotSize ;
2804+ continue ;
2805+ }
2806+
2807+ if ( length == 0 )
2808+ {
2809+ // Valid zero-length record (empty payload): nothing to read, keep scanning.
2810+ position += 4 ;
2811+ continue ;
2812+ }
2813+
2814+ if ( position + 4 + length > fs . Length )
2815+ {
2816+ break ;
2817+ }
2818+
2819+ byte [ ] recordData = new byte [ length ] ;
2820+ if ( fs . Read ( recordData ) < length )
2821+ {
2822+ break ;
2823+ }
2824+
2825+ if ( TryReadPrimaryKeyFromLegacyRecord ( recordData , pkCol , out var pkStr ) && deletedKeys . Contains ( pkStr ) )
2826+ {
2827+ matches . Add ( position ) ;
2828+ }
2829+
2830+ position += 4 + length ;
2831+ }
2832+ }
2833+ catch ( IOException )
2834+ {
2835+ return null ;
2836+ }
2837+
2838+ return matches ;
2839+ }
2840+
2841+ /// <summary>
2842+ /// Storage-layer scan used for encrypted per-record data files (and as the fallback when the
2843+ /// plaintext raw scan is unavailable): iterates the records through
2844+ /// <c>storage.ReadAllRecords</c>, which decrypts payloads and already skips tombstone markers.
2845+ /// </summary>
2846+ private List < long > ScanLegacyRemainingKeysViaStorage ( string dataFile , string pkCol , HashSet < string > deletedKeys )
2847+ {
2848+ var matches = new List < long > ( ) ;
2849+ foreach ( var ( recordOffset , recordData ) in this . storage ! . ReadAllRecords ( dataFile ) )
2850+ {
2851+ if ( TryReadPrimaryKeyFromLegacyRecord ( recordData , pkCol , out var pkStr ) && deletedKeys . Contains ( pkStr ) )
2852+ {
2853+ matches . Add ( recordOffset ) ;
2854+ }
2855+ }
2856+
2857+ return matches ;
2858+ }
2859+
2860+ /// <summary>
2861+ /// Walks a legacy variable-length record and returns the value of the primary-key column
2862+ /// (the same layout walk used by the reopen index rebuild).
2863+ /// </summary>
2864+ private bool TryReadPrimaryKeyFromLegacyRecord ( byte [ ] recordData , string pkCol , out string ? pkValue )
2865+ {
2866+ pkValue = null ;
2867+ try
2868+ {
2869+ int offset = 0 ;
2870+ for ( int i = 0 ; i < Columns . Count && offset < recordData . Length ; i ++ )
2871+ {
2872+ var value = ReadTypedValueFromSpan ( recordData . AsSpan ( offset ) , ColumnTypes [ i ] , out int bytesRead ) ;
2873+ if ( i == PrimaryKeyIndex && value != null )
2874+ {
2875+ pkValue = value . ToString ( ) ;
2876+ return true ;
2877+ }
2878+
2879+ offset += bytesRead ;
2880+ }
2881+ }
2882+ catch
2883+ {
2884+ // Corrupt / unexpected record — mirror the index-rebuild tolerance.
2885+ }
2886+
2887+ return false ;
2888+ }
2889+
27152890 /// <summary>
27162891 /// Deletes rows from the table that match the WHERE condition.
27172892 /// Routes through storage engine with different semantics:
0 commit comments