Arquitetura de Storage

Este documento mapeia tudo que o sistema armazena hoje, os problemas arquiteturais identificados, e a visão de evolução para um modelo de storage em camadas (hot/warm/cold). É um documento vivo — atualizar conforme decisões forem tomadas.

1. Estado atual

1.1 PostgreSQL (pacs_postgres — volume pacs_pg_data)

Armazena metadados estruturados mais blobs base64 que deveriam estar fora do DB.

Colunas com conteúdo grande

ColunaFormatoTamanho típicoCrescimento
users.signaturebase64 data URL (JPEG/PNG) — TEXT20-80 KB1 por user (~10/mês)
tenants.logobase64 data URL (JPEG/PNG) — TEXT50-500 KB1 por tenant
report_templates.assetsJSONB com background.data_url base64100 KB – 2 MB1-5 templates por unit
report_templates.layout / pageJSONB (config)1-5 KB
report_templates.contentHTML5-20 KB
reports.contentHTML do Tiptap2-5 KB (típico), até 50 KB1 por estudo reportado
report_revisions.previous_content + new_contentHTML (duplicado)Mesmo do reports.content1-5 por laudo corrigido
report_presets.default_contentHTML5-15 KB10-100 por tenant

Tabelas de metadata (pequenas)

TabelaVolume esperadoCrescimento
tenants1-10Muito lento
units3-20 por tenantLento
users10-100Lento
modalities10-30Mínimo
studies10k-500k~500-5k/mês
patients5k-200k~200-1k/mês
series30k-2M3-10× estudos
instances100k-10M10-50× séries
reports5k-100k50-500/mês

1.2 Filesystem (volume pacs_storage)

Montado em /app/pacs_mount (backend) e /app/storage (worker). Estrutura atual:
pacs_storage/
└── thumbnails/
    └── {orthanc_study_id}/
        ├── preview.jpg              (thumbnail do estudo — primeiro frame)
        └── series/
            └── {orthanc_series_id}.jpg  (thumbnails por série)
  • Gerador: worker/main.py durante indexação
  • Formato: JPEG 300×300 via pydicom + Pillow
  • Tamanho por arquivo: 10-50 KB
  • Retenção: nenhuma — thumbnails nunca são deletados
  • Estimativa: 500 estudos × 70 KB ≈ 35 MB/mês
Confirmado: só thumbnails hoje. Nada mais escrito em filesystem pelo backend.

1.3 Orthanc (pacs_orthanc — volume pacs_orthanc_data)

Armazena DICOM e índice SQLite embutido. Configuração em docker/orthanc/orthanc.json:
ConfigValorImplicação
StorageDirectory/var/lib/orthanc/dbBlobs DICOM + índice no mesmo volume
IndexDirectory/var/lib/orthanc/dbÍndice SQLite embutido
StorageCompressiontruegzip nos DICOM
MaximumStorageSize0Sem limite de crescimento
MaximumPatientCount0Sem limite de pacientes
Estimativa de crescimento (por modalidade, já comprimido):
ModalidadeTamanho/estudoExemplo @ 500 exames/mês
CT (100-300 slices)50-200 MB~75 GB/mês
MR (50-200 slices)30-100 MB~30 GB/mês
CR/DX (raio-x simples)1-5 MB~4 GB/mês
Total estimado (mix típico)~130 GB/mês

1.4 Redis (pacs_redis — volume pacs_redis_data)

Dados voláteis com TTL.
Chave/conjuntoPropósitoTTLTamanho
viewer_token:{uuid}Token temporário pro OHIF10 min~150 B/token
pacs_python_queueFila de jobs do workerFIFO (sem TTL)~500 B/job
JWT blacklistTokens invalidadosaté expirarvariável
Total em memória: ~100-150 MB típico. Persistência via RDB (snapshot).

2. Problemas identificados

P1 — report_templates.assets é bomba-relógio 🔴

O campo JSONB contém background.data_url (papel timbrado em base64), de 100 KB a 2 MB por template. O endpoint GET /tenant/templates pagina 50 itens por request → um único request pode trazer 100 MB. Cada leitura pra preview ou PDF puxa o payload completo. Preview em browser de celular pode engasgar. Impacto: alto. Usuário percebe na UI quando o tenant tem vários templates.

P2 — Blobs base64 inflando o DB 🟠

users.signature, tenants.logo e report_templates.assets (todos TEXT/JSONB base64) aumentam:
  • ~33% overhead (base64 vs binário)
  • Backup do PG inflado (backup de DB deve ser rápido; blobs pesados atrapalham)
  • WAL (write-ahead log) cresce mais rápido
  • Replicação (quando houver) fica lenta
  • Query plansSELECT * traz blobs sem querer; desenvolvedor precisa lembrar de excluir colunas
Impacto: médio hoje, alto quando escalar.

P3 — Orthanc com SQLite 🟠

Orthanc usa SQLite embutido por padrão (único arquivo .db). Limitações em SaaS multi-tenant:
  • Write serialization: file lock limita escrita concorrente. Várias modalidades de clínicas diferentes fazendo C-STORE simultaneamente = contenção.
  • Backup complexo: SQLite em uso precisa de WAL + .backup command; snapshot de volume arrisca corrupção.
  • Sem replicação nativa: single point of failure. Perda do volume = perda do índice (DICOM continua no storage, mas Orthanc não sabe mais onde).
  • Não escala horizontalmente: só um Orthanc pode abrir o SQLite por vez.
O Orthanc suporta PostgreSQL como índice via plugin oficial (orthanc-plugin-postgresql). Nesse modo:
  • Metadata DICOM em Postgres (o mesmo pacs_postgres ou dedicado)
  • Blobs DICOM continuam em filesystem (ou S3 via plugin separado)
  • Múltiplas instâncias Orthanc podem compartilhar o mesmo backend (scale out)
Impacto: baixo agora (dev), alto quando tiver várias clínicas concorrentes.

P4 — Orthanc sem política de retenção 🟡

MaximumStorageSize: 0. Com ~130 GB/mês, em 2 anos são 3 TB só de DICOM quente. Exigência legal típica para imagens médicas no Brasil: 20 anos (CFM 1.821/2007, com variações por estado e exame). Sem estratégia de arquivamento, o custo de disco cresce linear indefinidamente. Impacto: financeiro crescente, não-bloqueante no curto prazo.

P5 — Thumbnails sem retenção nem redundância 🟡

  • Cresce linear com estudos (~35 MB/mês)
  • Sem limpeza de thumbnails de estudos antigos
  • Se o volume pacs_storage for perdido, o worker teria que reprocessar todos (função pacs:requeue já existe)
  • Filesystem local não funciona com múltiplos backends (quando escalar)
Impacto: baixo. Gerenciável no atual single-server.

P6 — report_revisions duplica conteúdo 🟢

Audit trail imutável grava previous_content + new_content completos a cada correção. Para laudos de 5 KB é irrelevante; pra laudos com imagens inline (50 KB+) cresce rápido. Mitigação existente: compressão TOAST do Postgres já reduz overhead de TEXT longo. OK deixar como está. Impacto: mínimo.

3. Visão: Storage em camadas

Inspirado em como clínicas reais e PACS enterprise organizam dados. Não precisa implementar tudo agora — é o norte pra onde evoluir.

3.1 Conceito

CamadaLatênciaCustoO que vai aqui
🔥 HotmsaltoDado acessado múltiplas vezes/dia — metadata, estudos recentes, laudos ativos
♨️ WarmsegundosmédioAcessado ocasionalmente — estudos de meses atrás, thumbnails, logos
❄️ Coldminutos a horasmuito baixoArquivamento legal — estudos >2 anos, retenção regulatória

3.2 Aplicação ao nosso domínio

PostgreSQL (Hot — metadata)

Fica no Postgres: tudo que é metadado operacional + HTML pequeno.
  • studies, series, instances (índice)
  • patients, users, tenants, units, modalities
  • reports.content, report_revisions.*_content (HTML, caso típico 2-5 KB)
  • report_presets.default_content
Sai do Postgres (alvo: object storage):
  • users.signature{bucket}/signatures/{user_id}.{ext}
  • tenants.logo{bucket}/tenants/{tenant_id}/logo.{ext}
  • report_templates.assets.background{bucket}/templates/{template_id}/background.{ext}
Colunas do DB passam a guardar URL (ou path relativo) em vez do base64.

Object Storage (Warm — binários)

Opções:
  • MinIO (self-hosted, S3-compatible) — mesma infra on-prem, sem depender de cloud
  • AWS S3 — gerenciado, paga por GB + transfer
  • Backblaze B2 / Wasabi — S3-compatible mais barato, bom pra warm/cold
  • Cloudflare R2 — sem egress, bom pra servir assets públicos
O que vai no bucket warm:
  • Logos, signatures, template backgrounds (hoje em base64 no DB)
  • Thumbnails (quando escalar)
  • DICOM “mornos” — estudos com 3-24 meses de idade (via plugin Orthanc object-storage)
  • PDFs de laudos pré-gerados (futuro, se gerar no backend)
  • AWS S3 Glacier / Deep Archive — ~$1/TB/mês, retrieval em 12h
  • Cloudflare R2 com lifecycle
  • Backblaze B2 com retenção
  • Tape (off-site, LTO) — para quem já tem infra
O que vai no cold:
  • DICOMs com >2 anos (configurável pelo tenant, respeitando a retenção legal)
  • Backups antigos do Postgres (além da janela de PITR)

Migração entre camadas

Não é automática no Orthanc. Precisaria de um job cron:
  1. Query Postgres (via índice do Orthanc em Postgres): “estudos com study_date < today - 2 years e ainda marcados como quentes”
  2. Move o blob do bucket warm para o bucket cold
  3. Atualiza referência no Orthanc/Postgres
Ao abrir um estudo cold: o frontend avisa “estudo arquivado, liberação em X horas” e dispara job de restauração.

3.3 Arquitetura alvo resumida

┌──────────────────────────────────────────────────────────────┐
│                     APLICAÇÃO (Laravel + Next)               │
└─────┬──────────────────────┬───────────────────────┬─────────┘
      │                      │                       │
      ▼                      ▼                       ▼
┌──────────┐         ┌──────────────┐        ┌──────────────┐
│PostgreSQL│         │Object Storage│        │   Orthanc    │
│          │         │   (warm)     │        │              │
│ metadata │         │              │        │ hot DICOM    │
│ laudos   │◀───refs─│ logos/sigs   │        │ (PG index)   │
│          │         │ backgrounds  │◀───────│ via plugin   │
│          │         │ thumbnails   │        │ object-store │
└──────────┘         │ DICOM warm   │        └──────────────┘
                     └──────┬───────┘
                            │ lifecycle

                     ┌──────────────┐
                     │ Cold Storage │
                     │   (archive)  │
                     │ DICOM >2yrs  │
                     └──────────────┘

4. Próximos passos sugeridos (ordem de prioridade)

Não é um compromisso — é uma sugestão de ordem lógica.

Fase 1 — Tirar blobs do Postgres (~2 semanas de trabalho)

  1. Subir MinIO (um novo serviço no docker-compose.yml) com bucket pacs-assets
  2. Migrar report_templates.assets.background (maior ofensor)
    • Script one-off: lê base64, faz upload ao MinIO, atualiza coluna pra URL
    • Ajustar backend (ReportTemplateController) pra ler/escrever via S3 SDK
    • Ajustar preview + PDF pra baixar via URL com signed access
  3. Migrar tenants.logo e users.signature (mesmo padrão)
  4. Remover os campos base64 do DB (após migração validada)

Fase 2 — Orthanc com PostgreSQL index

  1. Instalar plugin orthanc-plugin-postgresql
  2. Configurar para apontar ao pacs_postgres (schema separado) ou DB dedicado
  3. Migração do SQLite → Postgres (Orthanc documenta o procedimento)
  4. Validar em homolog antes de produção

Fase 3 — DICOM em object storage

  1. Instalar plugin orthanc-plugin-object-storage (AWS/Azure/GCS S3-compatible)
  2. Configurar para gravar novos DICOMs no MinIO/S3
  3. Manter estudos existentes em filesystem (ou migrar em lote depois)

Fase 4 — Retenção e lifecycle

  1. Definir política por tenant (configurável): quanto tempo em hot/warm/cold
  2. Job cron de migração
  3. UI pra tenant admin configurar retenção

5. Questões em aberto

Itens em discussão — sem decisão ainda:
  • MinIO self-hosted vs AWS S3 — trade-off custo/complexidade (MinIO = mais infra, S3 = dependência de cloud e billing)
  • Orthanc: PostgreSQL plugin AGORA ou depois? — ver checklist de revisão
  • Política de retenção legal — qual o padrão CFM e variações estaduais aplicáveis? Definir com advogado antes de implementar cold tier
  • Pré-geração de PDFs — hoje o PDF é gerado no frontend sob demanda. Vale gravar uma versão no bucket após finalização do laudo (pra evitar regenerar toda vez)?
  • Thumbnails em object storage — migrar quando? Só faz sentido se houver >1 instância de backend, ou se o volume pacs_storage crescer a ponto de incomodar o backup

6. Metodologia de execução de fases

Antes de encostar em qualquer campo/tabela/módulo, mapear o raio de impacto: tudo que lê, escreve ou expõe aquele dado. Sem essa análise, mudanças grandes quebram silenciosamente componentes esquecidos.

6.1 Checklist de raio de impacto (por fase)

Para cada área afetada (um campo, uma tabela, ou um módulo como “Orthanc”), levantar:
  1. Migrations — schema atual e o que muda
  2. Models — casts, $hidden, mutators, accessors, relacionamentos
  3. Controllers — quem escreve, quem lê, quem expõe no JSON
  4. Services e helpers — wrappers como OrthancService, parsers tipo parseTemplateAssets, makeVisible('signature')
  5. Frontend — editores (uploaders), previews, geradores de PDF, listagens que expõem o dado
  6. API contracts — páginas do Mintlify (docs/api/*.md) que precisam atualizar
  7. Testes — unit/integration que validam o comportamento atual
  8. Consumidores externos — webhook do Orthanc, nginx auth_request, OHIF, worker Python

6.2 Exemplos por fase

Fase 1 — blobs base64 → object storage. Raio de impacto por campo:
  • users.signature — UserController@store/@update (escrita), ReportController@showByStudy/@upsertByStudy/@correctFinal (leitura via makeVisible), downloadStudyReportPdf no frontend
  • tenants.logo — TenantSettingsController@show/@update, ReportController (se eager load), PDF generator, componente de logo no header
  • report_templates.assets.background — ReportTemplateController@index/@show/@store/@update, ReportTemplateA4Preview, downloadStudyReportPdf, parseTemplateAssets helper
Fase 2 — Orthanc SQLite → PostgreSQL. Raio de impacto mais amplo:
  • Webhook Lua do Orthanc que dispara /api/orthanc/webhook
  • OrthancService no Laravel (wrapper REST)
  • Worker Python (consome via Orthanc REST)
  • Nginx CORS proxy (OHIF → Orthanc via DicomWeb + viewer token)
  • Queries que dependem de orthanc_study_id / orthanc_series_id — esses IDs continuam válidos porque são do próprio Orthanc; só o backend de índice muda
Fase 3 — DICOM em object storage. Adicional à Fase 2:
  • Config do plugin orthanc-plugin-object-storage
  • Comportamento do OHIF (WADO-RS continua via Orthanc, ele é quem resolve o blob no S3)
  • Latência de primeiro acesso a estudo “warm”

6.3 Fluxo de execução

  1. Disparar subagentes Explore em paralelo, um por área afetada, com prompt pedindo o raio de impacto estruturado
  2. Consolidar em docs/plans/{fase-x}.md — lista exaustiva de arquivos a tocar, ordem de execução, plano de rollback (como desfazer se der errado)
  3. Aprovação do plano antes de qualquer Edit — nunca partir pra implementação sem plano validado
  4. PRs pequenos reviewáveis — não um PR de 50 arquivos. Um por sub-passo (ex.: “migrar só backgrounds”, “remover colunas deprecadas”)
  5. Validação em homolog antes de fundir em main
  6. Rollback ensaiado — se o plano diz “script X reverte”, testar o revert num ambiente descartável antes

6.4 Aplicabilidade

Esta metodologia vale para qualquer refactor grande — não só storage. Exemplos futuros possíveis: trocar o editor Tiptap, migrar de Octane pra FrankenPHP, consolidar auth cookie + JWT. A mesma disciplina se aplica.

7. Referências