I/O, Streams & HTTP · Lesson 19

Incremental line processing with explicit record limits

Read text asynchronously while recognizing what the chosen limit does not bound.

Define a small line protocol before parsing rows

This lesson defines a deliberately small text protocol: a header name|amount followed by one record per line, with exactly two pipe-separated fields. It is not a general CSV reader and has no quoted-field or embedded-newline grammar. The caller supplies a maximum record count and a cancellation token. ReadRows returns a count and decimal total only after all rows have been accepted. The supplied data begins as a string converted to an in-memory byte stream, so the example exercises reader and parser behavior without claiming to stream an external file or network response.

Trace the header and two accepted records

ReadLineAsync first reads the header, which must match name|amount exactly. The loop then receives A|12.5 and B|4.25. Each accepted row increases the count, checks the two-field shape and parses the amount with invariant culture and an explicit NumberStyles option. The resulting count is two and the decimal total is 16.75. The caller formats that total using invariant culture so its expected output is independent of the device's decimal separator. The terminal newline does not represent another blank data record after the end of this supplied text.

Use strict decoding and keep the reader owned

The byte stream and StreamReader are owned by using declarations inside ReadRows. The reader is configured with a UTF8Encoding that throws on invalid bytes, disables byte-order-mark detection for this chosen protocol, and closes its underlying stream when disposed. The caller's token is forwarded into each asynchronous line read. The function's returned tuple contains independent scalar values rather than a borrowed reader or stream. The initial string and encoded byte array already exist in memory, so incremental line processing here does not imply that the complete input was never buffered earlier.

A record limit does not bound a single line

The maximumRows check bounds the number of records the function accepts; it does not bound the length of one line. ReadLineAsync constructs a complete line before the row-count test runs. A giant first data line can therefore consume substantial memory even with a limit of one row. The reader's small configured buffer is not a maximum line length. The code also allows an overflow while summing very large decimal amounts to propagate as a failure. A production importer needs explicit policies for byte volume, record size, numeric range and error reporting beyond this row-count boundary.

Practice: distinguish row-count and grammar failures

Add separate cases for an incorrect header, a third otherwise valid record when the maximum is two, an internal empty line, and a row containing an extra pipe. Predict which validation boundary rejects each input. Keep those cases independent rather than combining a bad header with an oversized record list. Then test zero allowed rows with a header-only document and with one valid data row. As a further contract change, explicitly reject negative maximumRows at entry and verify that rejection without needing to read any data.

Practice: expose the missing byte boundary

Feed a single very long line under a maximumRows value of one and explain why the record limit alone cannot reject it before allocation. Design an upstream byte-limited reader or a parser that enforces a record-length budget while accumulating characters. Account for multibyte UTF-8 when deciding whether the policy is in bytes or text units. You can reuse a bounded byte stream from the earlier HTTP lesson for a small controlled experiment. Do not present a reader buffer-size setting as an implementation of that stronger limit.

Review what incremental processing actually guarantees

Incremental processing describes when the parser asks for the next record, not every resource bound in the system. The baseline checks a precise header, two-field row grammar, invariant decimal values and an inclusive accepted-record budget. It returns no partial success if a later row is rejected. Preserve those decisions when adding diagnostics or an external adapter. If partial imports are a requirement, design their transaction and error policy explicitly rather than relying on local variables that happened to be updated before an exception. Keep parser checks separate from real storage or transport integration.

  • Name the line grammar; this pipe-delimited protocol is not general CSV.
  • Record count, record length and total bytes need distinct policies.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;

var result = await ReadRows("name|amount\nA|12.5\nB|4.25\n", 2, CancellationToken.None);
Console.WriteLine(result.Count); Console.WriteLine(result.Total.ToString("0.00", CultureInfo.InvariantCulture));
static async Task<(int Count, decimal Total)> ReadRows(string text, int maximumRows, CancellationToken token) {
    using var bytes = new MemoryStream(Encoding.UTF8.GetBytes(text)); using var reader = new StreamReader(bytes, new UTF8Encoding(false, true), false, 128, leaveOpen: false);
    if (await reader.ReadLineAsync(token) != "name|amount") throw new InvalidDataException("Header");
    int count = 0; decimal total = 0;
    while (await reader.ReadLineAsync(token) is string line) {
        if (++count > maximumRows) throw new InvalidDataException("Record limit"); string[] parts = line.Split('|');
        if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || !decimal.TryParse(parts[1], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal amount) || amount < 0) throw new InvalidDataException("Invalid row");
        total += amount;
    }
    return (count, total);
}

Expected output

2
16.75
Try this
A record-count cap is not a byte or line-length cap; preserve those different budgets explicitly.

Further reading