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:
- 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.
- 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 | |
|---|---|---|
| Complexidade | Baixa — um Stream normal, se encaixa em qualquer API que aceite Stream | Média/alta — precisa gerenciar PipeWriter/PipeReader, FlushResult, Advance |
| Backpressure por destino | Não — os dois destinos avançam em lockstep via Task.WhenAll; o mais lento segura o mais rápido a cada chunk | Sim — cada PipeReader tem buffer e ritmo próprios; um consumidor lento não trava o outro (até encher o buffer dele) |
| Alocações | Simples, 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ática | Mecanismo de fan-out visível em ~15 linhas | Exige 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
UploadPartAsyncnão é reprocessada; em produção isso pediria retry por parte e uso doAbortAsync()(que existe na classe, mas não é chamado em nenhumcatchdo 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.Httppara 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.