// TUTORIAL · STREAMING C# / S3

TeeStreamS3Tutorial

Como baixar um arquivo e enviar os mesmos bytes para o S3 em paralelo, sem baixar tudo primeiro e sem bufferizar o arquivo inteiro em memória — o padrão TeeStream, um Stream que faz fan-out de cada chunk para dois destinos ao mesmo tempo.

TUTORIAL .NET / C#STREAMINGAWS S3MINIOMULTIPART UPLOAD

Motivação

Baixar um arquivo de algum lugar e reenviar esse mesmo arquivo para um object storage é uma tarefa comum — migração de dados, espelhamento de backups, ingestão de arquivos de terceiros para um data lake. A forma óbvia de fazer isso é sequencial: baixar tudo para disco (ou memória), depois ler de novo e subir para o S3. Funciona, mas paga um preço que cresce junto com o tamanho do arquivo.

Este tutorial existe pra responder uma pergunta prática: dá pra ler a origem uma única vez e já escrever nos dois destinos ao mesmo tempo, com uso de memória constante, independente do tamanho do arquivo? A resposta é o padrão tee stream, batizado a partir do comando Unix tee — que lê a entrada padrão e escreve tanto na saída padrão quanto num arquivo, como um “T” hidráulico dividindo um fluxo em dois canos.

O problema (por que não dá só pra baixar e depois subir)

O caminho sequencial —

baixar arquivo inteiro → salvar em disco → ler o disco de novo → enviar para o S3

— tem dois custos que crescem junto com o arquivo:

  1. 2x o tempo de I/O: cada byte é lido e escrito duas vezes (uma vez do download, outra do upload), em duas fases sequenciais em vez de sobrepostas.
  2. Espaço proporcional ao arquivo inteiro: seja em disco, seja em memória, algo precisa guardar o arquivo completo antes do upload poder começar. Para 60 MiB isso não importa; para dezenas de GiB, importa — e o upload só começa a aparecer no destino depois que o download termina 100%.

A alternativa é ler a origem uma única vez, em chunks, e a cada chunk lido escrever simultaneamente nos dois destinos:

baixar (chunk a chunk) → [escrever no disco E enviar para o S3, ao mesmo tempo] → repetir

O arquivo começa a aparecer no S3 antes mesmo do download terminar, e a memória usada é proporcional ao tamanho de um chunk/parte, não ao arquivo inteiro.

Como funciona o TeeStream

flowchart LR
    subgraph Origem
        A["Servidor HTTP\nnginx local :8080\ntestfile.bin"]
    end

    subgraph App["App console .NET"]
        B["HttpClient\nGetStreamAsync\nchunks de 256 KiB"]
        C{{"TeeStream\nfan-out por chunk\nTask.WhenAll"}}
        D["FileStream\ndisco local"]
        E["S3MultipartUploadStream\nacumula até 5 MiB por parte"]
    end

    subgraph Destinos
        F[("Disco local\ndownloaded-testfile.bin")]
        G[("MinIO / S3\nbucket tutorial-bucket")]
    end

    A -- "ReadAsync" --> B
    B -- "WriteAsync por chunk" --> C
    C -- "escrita concorrente A" --> D
    C -- "escrita concorrente B" --> E
    D --> F
    E -- "UploadPartAsync a cada 5 MiB" --> G

    style C fill:#7c3aed,stroke:#333,color:#fff

Cada chunk lido da origem entra no TeeStream uma única vez e sai para os dois destinos ao mesmo tempo — não em duas passadas separadas. O núcleo do padrão inteiro cabe em ~15 linhas: um Stream write-only que recebe dois Streams de destino no construtor e, a cada WriteAsync, dispara a escrita para os dois concorrentemente:

public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
    var writeA = WriteToAAsync(buffer, offset, count, cancellationToken);
    var writeB = WriteToBAsync(buffer, offset, count, cancellationToken);
    await Task.WhenAll(writeA, writeB);
}

Reaproveitar o mesmo byte[] para as duas escritas é seguro porque nenhuma das duas muta o buffer — ambas só leem dele para enviar pela rede/disco.

O segundo destino, S3MultipartUploadStream, é outro Stream write-only, mas que fala com a API de multipart upload do S3 (InitiateMultipartUploadAsync / UploadPartAsync / CompleteMultipartUploadAsync). Ele acumula os bytes recebidos num MemoryStream até atingir o tamanho mínimo de parte exigido pelo S3 (5 MiB, o mínimo absoluto da API para qualquer parte que não seja a última), envia essa parte via UploadPartAsync, descarta o buffer e recomeça a acumular a próxima parte. Assim, a memória usada por esse stream é limitada a uma parte (5 MiB), não ao arquivo inteiro — que poderia ter gigabytes. Ao final do download, CompleteAsync() envia a última parte (que pode ser menor que 5 MiB — o S3 permite isso só na última) e fecha o upload.

O Program.cs orquestra tudo: baixa com HttpClient.GetStreamAsync, lê em chunks de 256 KiB e, para cada chunk, chama tee.WriteAsync(...). Cada destino loga, com timestamp relativo, seus bytes acumulados — por isso o log final mostra [DISCO] e [S3] avançando entrelaçados, não “todo o disco primeiro, todo o S3 depois”, provando na prática que a escrita é concorrente:

[  0,09s] [DISCO]    0,17 MiB escritos
[  0,24s] [DISCO]    0,42 MiB escritos
...
[  0,36s] [S3]    parte  1 enviada --    5,00 MiB acumulados
[  0,36s] [DISCO]    5,49 MiB escritos

Verificação de integridade: o tutorial gera um testfile.bin determinístico (~60 MiB, via scripts/generate-testfile.ps1) e, ao final, compara o SHA256 do arquivo original, da cópia em disco criada pelo app e do objeto recuperado de volta do MinIO. Os três batem — prova de que o fan-out concorrente não corrompe nem perde bytes no caminho.

Comparação com System.IO.Pipelines

Existem pelo menos duas formas idiomáticas de fazer “tee” em .NET, e o repositório documenta o trade-off explicitamente em vez de escondê-lo:

Stream customizado (usado aqui)System.IO.Pipelines
ComplexidadeBaixa — um Stream normal, se encaixa em qualquer API que aceite StreamMédia/alta — precisa gerenciar PipeWriter/PipeReader, FlushResult, Advance
Backpressure por destinoNão — os dois destinos avançam em lockstep via Task.WhenAll; o mais lento segura o mais rápido a cada chunkSim — cada PipeReader tem buffer e ritmo próprios; um consumidor lento não trava o outro (até encher o buffer dele)
AlocaçõesSimples, reaproveita o mesmo byte[] para as duas escritas (leitura concorrente é segura)Desenhado para ser allocation-conscious com Memory<byte>, mas com mais peças móveis
DidáticaMecanismo de fan-out visível em ~15 linhasExige entender a API de pipelines antes de ver o “tee” em si

O tutorial escolhe o Stream customizado porque o objetivo é ensinar o padrão tee stream da forma mais direta possível — o fan-out inteiro cabe num único método, sem exigir conhecimento prévio de System.IO.Pipelines. O preço desse acoplamento (destino lento segura o rápido) é real e importa em produção com destinos de latência muito diferente — para esse caso, o README do repositório inclui o esqueleto de como ficaria a versão com dois Pipes independentes, um por destino, cada um com seu próprio PauseWriterThreshold.

O que ficou como aprendizado (e limitações conhecidas)

Não há testes automatizados nem suíte de benchmark de throughput/memória neste tutorial — a verificação é manual (comparação de SHA256 descrita acima). Isso ficou documentado como próximo passo natural, junto com outras lacunas assumidas de propósito para manter o exemplo pequeno e didático:

  • Sem retry/backoff: uma falha no meio de UploadPartAsync não é reprocessada; em produção isso pediria retry por parte e uso do AbortAsync() (que existe na classe, mas não é chamado em nenhum catch do exemplo).
  • Tamanho de parte fixo em 5 MiB: o mínimo exigido pela API de multipart upload do S3, escolhido para gerar interleaving visível no log — não necessariamente o ideal para produção (partes maiores reduzem o número de chamadas, ao custo de mais memória por parte).
  • Acoplamento de velocidade entre os dois destinos, já coberto na comparação com Pipelines acima — o ponto de ensino central do repositório.

Stack

  • ◆ APLICAÇÃO: .NET 8/10 (console), System.Net.Http para o download, AWS SDK for .NET (AWSSDK.S3) para o multipart upload.
  • ▤ ARMAZENAMENTO: MinIO (S3-compatível) local, com ForcePathStyle = true — necessário porque servidores S3-compatíveis auto-hospedados esperam o bucket no path da URL, não em subdomínio.
  • ⌂ INFRA: Docker Compose orquestrando MinIO + um nginx local servindo o arquivo de teste — o tutorial roda 100% offline depois do primeiro docker compose up, sem conta AWS.

Como rodar

# 1. Gerar o arquivo de teste determinístico (~60 MiB), uma vez
./scripts/generate-testfile.ps1
# 2. Subir MinIO + servidor do arquivo de teste (Git Bash / WSL)
MSYS_NO_PATHCONV=1 docker compose up -d

# 3. Rodar o app: baixa via HTTP e replica para disco + S3 (MinIO) ao mesmo tempo
cd src/TeeStreamS3Tutorial
dotnet run

# 4. Verificar integridade (SHA256 do original, da cópia local e do objeto no MinIO)
certutil -hashfile testfile-source\testfile.bin SHA256
certutil -hashfile src\TeeStreamS3Tutorial\bin\Debug\net8.0\downloaded-testfile.bin SHA256

MinIO fica disponível em http://localhost:9000 (API) e http://localhost:9001 (console web, login minioadmin / minioadmin); o arquivo de teste é servido em http://localhost:8080/testfile.bin.

# Código aberto

O TeeStreamS3Tutorial é open source, roda 100% local (MinIO + nginx via Docker Compose) e serve de referência pro padrão tee stream em .NET.