On this page

Exporting reader rows through a channel

For several active report workers with an HTTP status/cancellation API, follow queued customer reports. This recipe focuses on copying a forward-only database reader into a channel safely.

A background export has two different kinds of forward-only source: a database reader advances one cursor, while a channel transfers independent work items between stages. Keeping that difference visible prevents a subtle bug: retaining several references to the same reader row does not produce several saved rows.

This example reads document identifiers and text from one query, snapshots each row, and writes a bounded stream of export jobs to a file worker. It uses ordinary DbCommand, ChannelWriter, ChannelReader and File APIs. No custom adapter factory is required for these recognized reader/channel sources.

Projecting rows before handing them off

The query returns two non-null columns in this order: a Guid identifier and a String body. The caller owns an open connection and its command. The producer owns only the reader it creates; it borrows the command and channel writer.

import Global.System
import Global.System.Data.Common
import Global.System.IO
import Global.System.Threading.Channels

public class ExportJob
    public Id as Guid = Guid.Empty
    public Contents as String = ""
end class

public class DocumentExportProducer
    public async action LoadAsync(command as DbCommand, output as ChannelWriter <of ExportJob>)
        require command is not nothing
        require output is not nothing
        Cancellation.ThrowIfCanceled()

        using reader = await command.ExecuteReaderAsync(Cancellation.Token)
            consume row from reader
                Cancellation.ThrowIfCanceled()
                if row.IsDBNull(0) or row.IsDBNull(1) then
                    throw new InvalidDataException("Document identifier and body must be present")
                end if

                let job = new ExportJob()
                job.Id = row.GetGuid(0)
                job.Contents = row.GetString(1)
                if job.Id = Guid.Empty then
                    throw new InvalidDataException("Document identifier must not be empty")
                end if
                await output.WriteAsync(job, Cancellation.Token)
            end consume
        end using
    end action
end class

The job contains independent values before WriteAsync can wait for channel capacity. The next ReadAsync can change the row view without changing an earlier job. Do not enqueue row itself. The producer publishes each job once and does not mutate it afterwards.

Reader consumption ends at the current result set. It does not call NextResult, close the writer or dispose the caller's command/connection. The surrounding using owns the created reader and performs its resolved type-directed resource cleanup on success, exception or cancellation. Asynchronous acquisition and row reads do not transfer that cleanup responsibility to the channel.

Consuming jobs sequentially

The worker receives a trusted, configured directory. Filenames come from Guid identifiers, never from text stored in the database. This avoids treating arbitrary row content as a relative filesystem path.

import Global.System.IO
import Global.System.Threading.Channels

public class DocumentExportWorker
    public async action RunAsync(jobs as ChannelReader <of ExportJob>, outputDirectory as String)
        require jobs is not nothing
        require outputDirectory is not whitespace
        Cancellation.ThrowIfCanceled()
        Directory.CreateDirectory(outputDirectory)

        consume job from jobs
            Cancellation.ThrowIfCanceled()
            require job is not nothing
            let filename = job.Id.ToString("N") + ".txt"
            let path = Path.Combine(outputDirectory, filename)
            await File.WriteAllTextAsync(path, job.Contents, Cancellation.Token)
        end consume
    end action
end class

Plain consume waits for the next item while an open channel is empty. Once the writer completes and its buffer is drained, the loop ends. Each file write finishes before the next job is processed; this is asynchronous I/O without overlapping worker bodies.

The explicit cancellation checks cover CPU-only work before the native cancellable operations. Cancellation is cooperative. A cancelled write can leave a partial file, and a completed file is not rolled back if a later job fails. The sample replaces an existing file with the same identifier. A production export may need temporary files and a publication step, a manifest, checksums or an external checkpoint; none is supplied implicitly by consume.

Connect the producer and consumer

The producer and file worker above contain the application logic in DirectSharp. A bounded channel connects them: the producer projects each database row into a retained job, and the worker writes one job at a time. This keeps the reader's borrowed current row out of later work.

The submitting operation owns the connection, command and linked cancellation source. It starts both stages, completes the writer when production finishes, and observes both outcomes before releasing their resources. A producer failure must release a waiting consumer; a worker failure must release a producer blocked by a full channel. Cancellation callbacks can fail too, so the hosting policy records those failures separately from the stage outcome.

A channel transfers process-local work; it is not a durable queue or an automatic acknowledgement/retry protocol. The buffer limits job count rather than bytes. The table supplies unique non-empty Id values and non-null Body text. Ordering makes successful output reproducible for a fixed query result; database isolation still determines which rows belong to that result.

The language example stays focused on the two DirectSharp stages. Their caller chooses and owns the surrounding cancellation and settlement policy.

Failure cases to exercise

Case Expected behavior
Query returns no rows Producer completes the writer; worker finishes without creating document files
Channel is temporarily empty Worker waits instead of treating the source as finished
Buffer reaches 32 jobs Producer waits for capacity; it does not drop a document
Two projected jobs remain buffered Each retains its own identifier and text as the reader advances
Null or empty identifier, null body, or wrong database column type Producer fails; host joins both stages and disposes owned resources
File access denied or disk full Worker fails; linked cancellation stops a waiting producer
Shutdown while waiting for a row, capacity or file I/O Native operations receive the stopping token; partial external output is possible
Producer finishes with buffered items Worker drains the remaining items before normal completion

See consumable contracts and the data access example for the surrounding resource conventions.