@@ -2,6 +2,7 @@ namespace SharpCoreDB.DataStructures;
22
33using System ;
44using System . Collections . Generic ;
5+ using System . Globalization ;
56using System . Runtime . CompilerServices ;
67using System . Threading ;
78using System . Buffers ;
@@ -1931,6 +1932,16 @@ internal void UpdateMultiple(List<(string where, Dictionary<string, object> upda
19311932 // remove the stale record from all indexes (unloaded indexes would later be rebuilt
19321933 // from the file INCLUDING the stale record).
19331934 EnsureAllRegisteredIndexesLoaded ( ) ;
1935+
1936+ // B8: single-pass contiguous UPDATE — when every operation is a `pk = <literal>` match on a
1937+ // plaintext fixed-width table with physically adjacent PK-ordered records, the old records
1938+ // are read as ONE contiguous byte range and patched in memory (no per-row pread). Strictly
1939+ // gated; any mismatch falls back to the generic per-row loop below.
1940+ if ( TryBulkUpdateContiguousFixedWidth ( engine , operations ) )
1941+ {
1942+ return ;
1943+ }
1944+
19341945 int appendedInBatch = 0 ; // only appends create stale versions that need compaction
19351946
19361947 foreach ( var ( where , updates ) in operations )
@@ -2316,6 +2327,222 @@ oldHashValues is not null &&
23162327 }
23172328 }
23182329
2330+ /// <summary>
2331+ /// B8: number of UPDATE batches processed by the contiguous single-pass fast path (diagnostics
2332+ /// used by tests to prove the path engages; zero means every batch fell back to the generic loop).
2333+ /// </summary>
2334+ public long BulkContiguousUpdateBatches => Interlocked . Read ( ref _bulkContiguousUpdateBatches ) ;
2335+
2336+ private long _bulkContiguousUpdateBatches ;
2337+
2338+ /// <summary>
2339+ /// B8: single-pass contiguous UPDATE fast path for plaintext fixed-width tables. Requires every
2340+ /// operation to be a simple <c>pk = <numeric literal></c> match on the primary key, with keys
2341+ /// strictly increasing AND records physically adjacent in the data file (the fixed-width layout
2342+ /// makes the on-disk stride constant: <c>[4-byte length][FixedSize payload]</c>). When all
2343+ /// conditions hold the target records are read as one contiguous byte range, patched in memory
2344+ /// (field-level in-place patch, exactly like the generic row path) and written back through the
2345+ /// same buffered same-length overwrite. Any mismatch returns <see langword="false"/> so the caller
2346+ /// falls back to the generic per-row loop — no records are touched before the range is verified.
2347+ /// </summary>
2348+ private bool TryBulkUpdateContiguousFixedWidth (
2349+ IStorageEngine engine ,
2350+ List < ( string where , Dictionary < string , object > updates ) > operations )
2351+ {
2352+ int count = operations . Count ;
2353+ if ( count < 2 )
2354+ {
2355+ return false ;
2356+ }
2357+
2358+ // Narrow, conservative gate: fixed-width columnar table with an explicit PK, plaintext
2359+ // records only (a raw contiguous read must equal the logical record bytes), no buffered
2360+ // overwrites for this file, and no CHECK constraints (mirrors the generic fastPatch gate).
2361+ if ( ! _fixedWidthRecords ||
2362+ StorageMode != StorageMode . Columnar ||
2363+ this . PrimaryKeyIndex < 0 ||
2364+ this . TableCheckConstraints . Count > 0 ||
2365+ HasColumnCheckConstraints ( ) ||
2366+ this . storage is null ||
2367+ this . _config is not { NoEncryptMode : true } ||
2368+ this . storage . HasBufferedOverwrite ( DataFile ) )
2369+ {
2370+ return false ;
2371+ }
2372+
2373+ var pkName = this . Columns [ this . PrimaryKeyIndex ] ;
2374+ var layout = GetFixedWidthLayout ( ) ;
2375+ long stride = 4L + layout . FixedSize ;
2376+
2377+ var positions = new long [ count ] ;
2378+ var keys = new string [ count ] ;
2379+ var repointColumns = new List < int > ? [ count ] ;
2380+ long parsedPrev = 0 ;
2381+
2382+ for ( int i = 0 ; i < count ; i ++ )
2383+ {
2384+ var ( where , updates ) = operations [ i ] ;
2385+ if ( string . IsNullOrEmpty ( where ) ||
2386+ ! TryParseSimpleWhereClause ( where , out var whereCol , out var whereVal ) ||
2387+ ! string . Equals ( whereCol , pkName , StringComparison . OrdinalIgnoreCase ) ||
2388+ whereVal is null )
2389+ {
2390+ return false ;
2391+ }
2392+
2393+ var keyStr = whereVal . ToString ( ) ;
2394+ if ( string . IsNullOrEmpty ( keyStr ) )
2395+ {
2396+ return false ;
2397+ }
2398+
2399+ foreach ( var updateKey in updates . Keys )
2400+ {
2401+ // Resolve the column; unknown columns must fall back so the generic loop surfaces the error.
2402+ int colIdx = - 1 ;
2403+ for ( int c = 0 ; c < this . Columns . Count ; c ++ )
2404+ {
2405+ if ( this . Columns [ c ] . Equals ( updateKey , StringComparison . OrdinalIgnoreCase ) )
2406+ {
2407+ colIdx = c ;
2408+ break ;
2409+ }
2410+ }
2411+
2412+ if ( colIdx < 0 )
2413+ {
2414+ return false ;
2415+ }
2416+
2417+ // Updating the PK itself must fall back (the PK B-tree position must be re-pointed).
2418+ if ( colIdx == this . PrimaryKeyIndex )
2419+ {
2420+ return false ;
2421+ }
2422+
2423+ // Hash-indexed SET columns need their entries re-pointed after the in-place write.
2424+ // Fixed-size values decode from the raw slot cheaply; variable-length (TEXT/BLOB)
2425+ // indexed columns would need an overflow-arena read, so they fall back to the
2426+ // generic per-row loop.
2427+ if ( this . hashIndexes . ContainsKey ( this . Columns [ colIdx ] ) )
2428+ {
2429+ if ( layout . IsVariable [ colIdx ] )
2430+ {
2431+ return false ;
2432+ }
2433+
2434+ ( repointColumns [ i ] ??= new List < int > ( 2 ) ) . Add ( colIdx ) ;
2435+ }
2436+ }
2437+
2438+ // Strictly increasing numeric keys keep the physical records adjacent for fixed-width.
2439+ if ( ! long . TryParse ( keyStr , NumberStyles . Integer , CultureInfo . InvariantCulture , out long key ) )
2440+ {
2441+ return false ;
2442+ }
2443+
2444+ if ( i > 0 && key <= parsedPrev )
2445+ {
2446+ return false ;
2447+ }
2448+
2449+ parsedPrev = key ;
2450+ keys [ i ] = keyStr ;
2451+ }
2452+
2453+ // Resolve the first record position through the PK B-tree, then require every later key to
2454+ // sit at the expected contiguous offset (physical adjacency). Verifying every position keeps
2455+ // the gate sound even when earlier appends/deletes shifted records.
2456+ var first = this . Index . Search ( keys [ 0 ] ) ;
2457+ if ( ! first . Found )
2458+ {
2459+ return false ;
2460+ }
2461+
2462+ long basePosition = first . Value ;
2463+ positions [ 0 ] = basePosition ;
2464+ long expected = basePosition ;
2465+ for ( int i = 1 ; i < count ; i ++ )
2466+ {
2467+ expected += stride ;
2468+ var search = this . Index . Search ( keys [ i ] ) ;
2469+ if ( ! search . Found || search . Value != expected )
2470+ {
2471+ return false ;
2472+ }
2473+
2474+ positions [ i ] = expected ;
2475+ }
2476+
2477+ // Read the whole contiguous span in ONE range read (plaintext records only — enforced above).
2478+ long totalBytes = stride * count ;
2479+ if ( totalBytes <= 0 || totalBytes > int . MaxValue )
2480+ {
2481+ return false ;
2482+ }
2483+
2484+ var raw = this . storage . ReadBytesRange ( DataFile , basePosition , ( int ) totalBytes ) ;
2485+ if ( raw is null || raw . Length < totalBytes )
2486+ {
2487+ return false ;
2488+ }
2489+
2490+ // Verify every 4-byte length prefix matches the fixed record size BEFORE touching anything.
2491+ for ( int i = 0 ; i < count ; i ++ )
2492+ {
2493+ int prefix = BinaryPrimitives . ReadInt32LittleEndian ( raw . AsSpan ( ( int ) ( i * stride ) , 4 ) ) ;
2494+ if ( prefix != layout . FixedSize )
2495+ {
2496+ return false ;
2497+ }
2498+ }
2499+
2500+ // Patch and write each record in place (buffered by the storage layer; flushed at commit).
2501+ for ( int i = 0 ; i < count ; i ++ )
2502+ {
2503+ var payload = new byte [ layout . FixedSize ] ;
2504+ raw . AsSpan ( ( int ) ( i * stride ) + 4 , layout . FixedSize ) . CopyTo ( payload ) ;
2505+
2506+ var patched = TryOverwriteFixedWidthInPlace ( payload , operations [ i ] . updates ) ;
2507+ if ( patched is null || ! engine . TryUpdateInPlaceSameLength ( Name , positions [ i ] , patched ) )
2508+ {
2509+ return false ;
2510+ }
2511+
2512+ // Re-point hash-index entries for every fixed-size indexed SET column (mirrors the
2513+ // generic fastPatch path: old value decoded from the pre-write record, new value at the
2514+ // same position).
2515+ var repoints = repointColumns [ i ] ;
2516+ if ( repoints is { Count : > 0 } )
2517+ {
2518+ foreach ( var colIdx in repoints )
2519+ {
2520+ var colName = this . Columns [ colIdx ] ;
2521+ if ( ! this . hashIndexes . TryGetValue ( colName , out var hashIdx ) )
2522+ {
2523+ continue ;
2524+ }
2525+
2526+ var slot = payload . AsSpan ( layout . Offsets [ colIdx ] , layout . SlotSizes [ colIdx ] ) ;
2527+ var oldVal = ReadTypedValueFromSpan ( slot , this . ColumnTypes [ colIdx ] , out _ ) ;
2528+ if ( oldVal is not null )
2529+ {
2530+ hashIdx . Remove ( oldVal , positions [ i ] ) ;
2531+ }
2532+
2533+ var newVal = operations [ i ] . updates [ colName ] ;
2534+ if ( newVal is not null )
2535+ {
2536+ hashIdx . Add ( newVal , positions [ i ] ) ;
2537+ }
2538+ }
2539+ }
2540+ }
2541+
2542+ Interlocked . Increment ( ref _bulkContiguousUpdateBatches ) ;
2543+ return true ;
2544+ }
2545+
23192546 /// <summary>
23202547 /// True when any column carries a CHECK expression (the batch fast-patch path is disabled in
23212548 /// that case because a CHECK may read non-updated columns).
0 commit comments