📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

You need to import a 20 GB CSV file into SQL Server every night from a .NET service running in a container with a 1 GB memory limit. How would you design it?

Designing a memory-bounded, resumable large-file import: streaming, low-allocation parsing, bounded pipelines, bulk insert, checkpoints and bad-row handling.

Senior RealWorldScenario Advanced channels etl file-system large-files performance sqlbulkcopy streams

๐ŸŽฏ Key points

  • Stream the file instead of loading it, so memory is proportional to batch size and channel capacity, not file size.
  • Use a real CSV parser (quotes, embedded commas and newlines) with low allocation, such as Sylvan.Data.Csv exposing an IDataReader, or PipeReader plus Utf8Parser.
  • A bounded Channel with FullMode = Wait gives back-pressure between the reader and the writers, keeping memory flat when the database slows down.
  • Load with SqlBulkCopy (TableLock, EnableStreaming, batches of 10,000 to 50,000) into an unindexed staging heap for minimal logging, then build indexes on staging.
  • Publish atomically: only after every batch has committed and validation passes (row counts, totals, reject threshold), run ALTER TABLE ... SWITCH or a set-based MERGE in one transaction.
  • Checkpoint the last contiguous committed batch (line number or exact byte offset) so a failure resumes rather than restarts, and guard reruns with a file hash for idempotency.
  • Profile before adding parallelism: dotnet-counters for memory and GC, SQL wait stats and log write rate for the database; the bottleneck is usually transaction log and index maintenance, not parsing.
  • Operate it like a product: rows per second, ETA and reject metrics, an alert if it will miss its window, a reject file with line numbers, and a dry-run validation mode.

โฑ 30-second answer

Never load the file. Stream it: open with a large buffer and SequentialScan, parse line by line with a fast CSV parser (or PipeReader + UTF-8 parsing), and push rows into a bounded Channel so the reader cannot outrun the writers. Insert in batches (e.g. 10,000โ€“50,000 rows) with SqlBulkCopy into a staging table, then merge into the real table in one transaction.

Make it resumable (checkpoint byte offset or line number per committed batch), send bad rows to a reject file with line numbers, make the load idempotent (file hash, staging swap), and emit progress metrics. Memory stays at a few hundred MB regardless of file size.

๐Ÿ—ฃ Full answer (senior)

Design

Blob / disk file (20 GB)
   โ”‚ FileStream, 1 MiB buffer, SequentialScan
   โ–ผ
Reader task: parse rows (low allocation)
   โ”‚ bounded Channel<Row[]> (capacity 4 batches)  โ† back-pressure
   โ–ผ
2โ€“4 writer tasks: SqlBulkCopy batches โ”€โ”€โ–บ staging table (no indexes, minimal logging)
   โ”‚
   โ–ผ
One transaction: validate + MERGE/SWITCH staging โ†’ target
   โ”‚
   โ–ผ
Checkpoint table: file hash, last committed offset, row counts, rejects

Key decisions

Concern Choice Why
Memory Stream, bounded channel, reuse buffers Memory proportional to batch size, not file size
Parsing Fast parser (Sylvan.Data.Csv, CsvHelper) or PipeReader + Utf8Parser Avoid a string per field; correct quote handling
Insert SqlBulkCopy with IDataReader streaming, TableLock, batch size 10โ€“50k 10โ€“100x faster than row-by-row inserts
Atomicity Load to staging, then switch/merge Readers never see a half-loaded day
Restart Checkpoint per committed batch A failure at 80% resumes, not restarts
Bad data Reject file with line number and reason; fail if > threshold One bad row should not kill the run, a bad file should
Observability Rows/s, bytes read, rejects, lag Know if it will finish before business hours

Production example

We replaced a job that called File.ReadAllLines and inserted with EF Core SaveChanges per 1,000 rows. It needed 30 GB of memory and 6 hours. The new design streamed through a DbDataReader from Sylvan's CSV reader straight into SqlBulkCopy (EnableStreaming = true), with 4 parallel writers into a heap staging table and a final ALTER TABLE ... SWITCH. It ran in 25 minutes with a peak of 300 MB and resumed from the last checkpoint after a node eviction.

๐Ÿ” What the interviewer is checking

A realistic senior design question that combines file I/O, memory, concurrency and database loading.

Green flags

  • Streams instead of loading; explains memory bound.
  • Uses bounded back-pressure (Channels) and bulk insert.
  • Thinks about atomicity (staging and swap), restart and idempotency.
  • Handles bad rows and observability.

Red flags

  • ReadAllLines or loading into a List first.
  • Row-by-row inserts via an ORM.
  • No plan for failure halfway.

๐Ÿ“š Learn in depth

1. Why naive approaches fail

File.ReadAllLines(20 GB)  โ†’  ~20 GB of UTF-16 strings = ~40 GB + array โ†’ OOMKilled
EF Core Add + SaveChanges โ†’  change tracking per entity, one INSERT per row โ†’ hours

2. Back-pressure with bounded channels

var channel = Channel.CreateBounded<Row[]>(new BoundedChannelOptions(4)
{
    FullMode = BoundedChannelFullMode.Wait,
    SingleReader = false,
    SingleWriter = true
});

When the database is slow, the channel fills and WriteAsync waits, so the reader pauses instead of buffering gigabytes. Memory is roughly capacity ร— batch size ร— row size.

3. Parsing without allocating per field

A CSV row of 20 fields as string.Split creates 21 strings. For 100 million rows that is 2 billion allocations and heavy GC. Options:

  • A parser that exposes fields as ReadOnlySpan<char> and parses numbers and dates directly from spans.
  • PipeReader over the file, find newlines in ReadOnlySequence<byte>, parse UTF-8 with Utf8Parser / int.TryParse(ReadOnlySpan<byte>).
  • Only materialise strings for text columns you store.

CSV is not "split on commas": quoted fields can contain commas and newlines, so use a real parser.

4. Parallelism options

Approach When
One reader, N writers Default. Parsing is rarely the bottleneck; the database is
Split file into byte ranges with RandomAccess Parsing is CPU-bound and rows never contain embedded newlines; align each range to the next newline
Multiple files in parallel Upstream can split the export

5. Idempotency and restart

  • Store the file's name, size and hash before starting; skip if already loaded.
  • Checkpoint progress in the same transaction as each batch, or make batches idempotent with a batch id column.
  • Byte offsets need care: StreamReader reads ahead in large blocks, so FileStream.Position is not the position of the current line. Either track offsets yourself (count the UTF-8 bytes of each line plus its newline, or use a PipeReader where you see exact positions), or checkpoint the line number and skip that many lines on restart (simpler, slower to resume).
  • With several writers, batches can commit out of order. Only advance the checkpoint to the highest batch where all earlier batches have committed.
  • The final swap is one transaction, so a half-finished run never becomes visible.

6. SQL Server specifics

  • SqlBulkCopy with SqlBulkCopyOptions.TableLock into an empty heap staging table enables minimal logging in the simple or bulk-logged recovery model.
  • Load into staging with no non-clustered indexes, then build the indexes on staging after the load. ALTER TABLE ... SWITCH requires staging and target to have the same columns, indexes, constraints and filegroup, so the swap is metadata-only and instant.
  • If matching the schema for SWITCH is not practical, use a set-based MERGE or INSERT ... SELECT in batches instead.
  • EnableStreaming = true matters when the source is a streaming IDataReader, so rows flow through without buffering.
  • BatchSize 10,000โ€“50,000 is a good start; measure.

7. Try it yourself

  1. Generate a 2 GB CSV with a script.
  2. Implement version 1 with File.ReadAllLines and watch memory in dotnet-counters.
  3. Implement version 2 with a streaming parser, bounded channel and SqlBulkCopy against a local SQL Server container.
  4. Kill the process halfway, restart, and confirm it resumes from the checkpoint without duplicates.

๐Ÿ’ป Code

public sealed class NightlyImport(string connectionString, ILogger<NightlyImport> log)
{
    private const int BatchSize = 20_000;

    public async Task RunAsync(string path, long startOffset, CancellationToken ct)
    {
        var channel = Channel.CreateBounded<List<OrderRow>>(new BoundedChannelOptions(4)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleWriter = true
        });

        var writers = Enumerable.Range(0, 3)
            .Select(_ => WriteBatchesAsync(channel.Reader, ct))
            .ToArray();

        await ReadFileAsync(path, startOffset, channel.Writer, ct);
        await Task.WhenAll(writers);
    }

    private async Task ReadFileAsync(string path, long startOffset,
        ChannelWriter<List<OrderRow>> output, CancellationToken ct)
    {
        try
        {
            await using var fs = new FileStream(path, new FileStreamOptions
            {
                Mode = FileMode.Open, Access = FileAccess.Read, Share = FileShare.Read,
                BufferSize = 1024 * 1024,
                Options = FileOptions.SequentialScan | FileOptions.Asynchronous
            });
            fs.Seek(startOffset, SeekOrigin.Begin);
            using var reader = new StreamReader(fs, Encoding.UTF8);

            var batch = new List<OrderRow>(BatchSize);
            long lineNo = 0;
            while (await reader.ReadLineAsync(ct) is { } line)
            {
                lineNo++;
                if (OrderRow.TryParse(line, out var row)) batch.Add(row);
                else log.LogWarning("Rejected line {Line}", lineNo); // also write to a reject file

                if (batch.Count == BatchSize)
                {
                    await output.WriteAsync(batch, ct);   // waits when writers fall behind
                    batch = new List<OrderRow>(BatchSize);
                }
            }
            if (batch.Count > 0) await output.WriteAsync(batch, ct);
        }
        finally
        {
            output.Complete();
        }
    }

    private async Task WriteBatchesAsync(ChannelReader<List<OrderRow>> input, CancellationToken ct)
    {
        await foreach (var batch in input.ReadAllAsync(ct))
        {
            await using var conn = new SqlConnection(connectionString);
            await conn.OpenAsync(ct);
            using var bulk = new SqlBulkCopy(conn, SqlBulkCopyOptions.TableLock, null)
            {
                DestinationTableName = "staging.Orders",
                BatchSize = batch.Count,
                EnableStreaming = true,
                BulkCopyTimeout = 0
            };
            await bulk.WriteToServerAsync(OrderRow.ToDataReader(batch), ct);
            // record checkpoint for this batch here
        }
    }
}
-- Final step, one transaction: swap staging into place
BEGIN TRAN;
  TRUNCATE TABLE dbo.Orders_Old;
  ALTER TABLE dbo.Orders SWITCH TO dbo.Orders_Old;
  ALTER TABLE staging.Orders SWITCH TO dbo.Orders;
COMMIT;

โš  Common mistakes

Mistake: Reading the whole file with File.ReadAllLines or ReadAllText. Why it is wrong: A 20 GB UTF-8 file becomes roughly 40 GB of UTF-16 strings, so the container is OOMKilled long before the import starts. Instead: Stream with StreamReader or PipeReader and keep only a few batches in memory.

Mistake: Inserting with EF Core SaveChanges or one INSERT per row. Why it is wrong: Change tracking and a round trip per row turn minutes of work into hours. Instead: Use SqlBulkCopy into a staging table.

Mistake: Using an unbounded queue between reading and writing. Why it is wrong: Parsing is faster than the database, so the queue grows until memory runs out. Instead: Use a bounded Channel with FullMode = Wait.

Mistake: Making the data visible before the load is complete. Why it is wrong: Writing straight into the live table, or switching after a failed batch, exposes partial or duplicated data to users and reports. Instead: Load into staging, wait for every writer to finish successfully, validate counts, then switch or merge in one transaction.

Mistake: Adding parallel parsers or more writers without measuring. Why it is wrong: If the transaction log or index maintenance is the bottleneck, more writers only add lock and log contention. Instead: Profile first (dotnet-counters, SQL wait stats, log write throughput) and tune batch size, minimal logging and staging indexes before adding threads.

๐Ÿ› Senior insight: trade-offs

The design goal is memory proportional to the batch, not the file, and failure is normal. Streaming and a bounded channel give predictable memory; staging plus an atomic swap gives consistency; checkpoints give resumability; a reject file and thresholds turn bad data into an operational decision.

Option Benefit Cost or risk When to use it
ALTER TABLE ... SWITCH from staging Metadata-only, instant publish Staging must match target schema, indexes and filegroup Full nightly replace
Partition switch into a date partition Load one day without touching others Needs a partitioned target and aligned indexes Append-only history tables
Set-based MERGE or INSERT ... SELECT Works without schema matching Logged, slower, can block readers Upserts, small deltas
More parallel writers Higher throughput if the database has headroom Log and lock contention After profiling shows spare capacity
Columnstore target Great compression and analytics Batches under 102,400 rows land in delta stores Reporting tables

In Azure, I check the database tier's log rate and IOPS limits (Azure SQL throttles log write throughput), watch the container's memory in Azure Monitor or Prometheus, and emit OpenTelemetry metrics for rows per second and ETA so an alert fires before the run misses its window.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate is vague about streaming Why: Checks Stream fundamentals
    How to answer

    A Stream is a sequence of bytes you read or write in order, without needing it all in memory. Stream is an abstract class with Read/Write (sync and async), optional Seek/Position/Length (only if CanSeek), and Flush/Dispose.

    • Backing streams talk to a store: FileStream, MemoryStream, NetworkStream.
    • Wrapper (decorator) streams transform data on its way through: BufferedStream, GZipStream, BrotliStream, CryptoStream, SslStream.
    • Readers and writers add text or binary formats on top: StreamReader, StreamWriter, BinaryReader.

    Key gotcha: Read may return fewer bytes than you asked for, and 0 means end of stream. Loop, or use ReadExactly (throws EndOfStreamException if the stream ends early) or ReadAtLeast, both available in .NET 10.

  2. When: Candidate proposes splitting the file for parallel parsing Why: Probes RandomAccess and access patterns
    How to answer
    • Sequential access reads or writes bytes in order (log files, CSV imports, backups). The OS reads ahead, the device streams contiguous blocks, and throughput is at its highest.
    • Random access jumps to arbitrary offsets (database pages, indexes, lookups in a big binary file). Each request pays a positioning cost: ~5โ€“10 ms seek on an HDD, ~10โ€“100 ยตs on an SSD.

    In .NET: use a normal FileStream (optionally FileOptions.SequentialScan and a bigger buffer) for sequential work. For random work use RandomAccess.Read/Write with a SafeFileHandle (offset-based, thread-safe, no shared Position), FileOptions.RandomAccess, or a MemoryMappedFile when you want the OS to page data in for you.

  3. When: Candidate mentions the container memory limit Why: Connects to working set and OOMKilled
    How to answer

    RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.

๐Ÿ›  Real-world problems: how you handle them

Nightly import rewritten from 6 hours and 30 GB to 25 minutes and 300 MB
Situation
[Order history import] from a [partner] producing a [20 GB] CSV nightly, loaded into [SQL Server] by a .NET [10] worker in [Kubernetes].
Signals
Job needed a [32 GB] node, often ran past [6 am] into business hours, and twice left partial data after node evictions.
Investigation
Profiled with dotnet-counters and SQL wait stats: memory dominated by strings from `ReadAllLines`, database time dominated by row-by-row inserts with EF Core change tracking.
Resolution
Rebuilt as streaming parse → bounded channel → 3 `SqlBulkCopy` writers into a staging heap → `ALTER TABLE SWITCH`. Added checkpoints, a reject file and a file-hash guard.
Prevention
Metrics for rows/s and ETA with an alert if the run would finish after [5 am], a monthly restore-and-rerun test, and load tests with a synthetic [25 GB] file.

How to tell it (STAR)

**S:** Our nightly [partner import] took [6 hours] on a [32 GB] node and sometimes left half-loaded data. **T:** I owned the redesign with a [1 GB] container limit. **A:** I streamed the file through a proper CSV parser into a bounded channel, used `SqlBulkCopy` into a staging table with three writers, and swapped it in with one transaction. I added checkpoints so an eviction resumes, and a reject file for bad rows. **R:** It now runs in [25 minutes] with [300 MB] peak memory, survived [two] evictions without duplicates, and we retired the large node.

โ†ฉ Usually asked after

More in File System

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me