Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions NeoReports-Decisoes.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,27 @@ Removido da v1 vs. Cap. 16: `IRetryPolicy`, `IExceptionClassifier`, `IAuthProvid
| D8 | Disparo/sync | Reports registrados por nome; sem config dinâmica; sync = single-output |
| D9 | Abstractions | Mínimo typed-only, congelado, SemVer estrito |
| D10 | Filtro | Delegates C# tipados; JsonLogic/DynamicLinq pós-MVP |
| D11 | Retry/Skip | Retry (Polly) envolve a leitura do batch; falha de leitura não é "skippável" (sem cursor pra avançar) → vira Abort; falha de projeção/escrita é skippável (cursor já conhecido) |
| D12 | Map no builder | `Map` não é um passo que troca o tipo do builder; o mapeamento é expresso por `From(source, map)`, mantendo `ReportBuilder<TRow>` mono-genérico e compatível com `AddReport<TRow>(Action<...>)` |
| — | Design | Já feito no Claude Design; exportar conforme handoff; UI pós-MVP |

---

## D11 — Semântica de retry, skip e threshold (Core / PR 2)

**Decisão.**
- A unidade de resiliência é a **leitura de um batch**. A `ResiliencePipeline` (Polly v8) envolve `reader.ReadAsync` (leitura + filtro + projeção). `MaxAttempts` inclui a primeira tentativa (`MaxRetryAttempts = MaxAttempts - 1`). Cancelamento (`OperationCanceledException`) nunca é retentado.
- **Falha de leitura** depois de esgotado o retry **não é "skippável"**: sem um batch lido não há `NextCursor` para avançar a paginação keyset, então pular silenciosamente truncaria dados. Nesse caso, mesmo em modo skip, o report **aborta** (status `Failed`).
- **Falha de projeção/escrita** de um batch já lido **é skippável**: o `NextCursor` já é conhecido, então `SkipBatchAndLog` descarta aquele batch, loga warning estruturado e marca o report como **parcial** (`CompletedPartial`), seguindo para o próximo cursor.
- O `IFailureStrategy` recebe contadores (consecutivas/total/razão) via `BatchFailureContext`; `SkipBatchAndLog().AbortIf(t => t.ConsecutiveFailures(n))` escala para Abort quando o threshold é atingido.
- **Premissa de atomicidade do writer:** writers devem escrever um batch de forma atômica (bufferizar e dar flush) para que o skip não deixe linha parcial. Saída vai para arquivo temporário por execução; publicação (upload) acontece só no fim (alinha com D2: restart-do-zero, publicação atômica).

**Por quê.** Retry resolve transitórios de leitura (CA-11); skip + threshold dão resiliência a falhas definitivas sem corromper ordenação keyset (CA-12/13/14). Separar leitura (retentável, idempotente) de escrita (não re-escrita) evita escrita dupla no stream de saída.

---

## D12 — `Map` via overload de `From`, builder mono-genérico (Core / PR 2)

**Decisão.** `ReportBuilder<TRow>` é genérico **apenas** sobre o tipo de linha final `TRow`. O mapeamento de um tipo de origem diferente é expresso por overloads `From<TSource>(IBatchSource<TSource>, Func<TSource,TRow>)` / `From<TSource>(IStreamingSource<TSource>, Func<TSource,TRow>)`, que adaptam a source via `MappingBatchSource`/`MappingStreamingSource`.

**Por quê.** Um passo `Map<TOut>` que troca o tipo do builder quebraria o padrão de registro `AddReport<TRow>("nome", Action<ReportBuilder<TRow>>)` (a lambda continuaria num builder de outro tipo enquanto o registro buildaria o original). O overload de `From` entrega a mesma capacidade ("Map para um tipo de saída" da spec) sem essa armadilha e sem segundo parâmetro genérico no builder. Colunas são declaradas com `.Column(v => v.X, "Header")` (infere `ColumnType` do tipo do membro) ou `Columns(Col(...))`.
30 changes: 30 additions & 0 deletions NeoReports.sln
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{4444
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Abstractions", "src\NeoReports.Abstractions\NeoReports.Abstractions.csproj", "{7FFF164E-CCD7-448F-8E2A-F0D8F958065F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Core", "src\NeoReports.Core\NeoReports.Core.csproj", "{EB14C940-E3EA-41F3-9510-6CE73FABA41A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Core.UnitTests", "tests\NeoReports.Core.UnitTests\NeoReports.Core.UnitTests.csproj", "{D2208288-ACB6-477E-B8CF-C165A6F0155D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -35,11 +39,37 @@ Global
{7FFF164E-CCD7-448F-8E2A-F0D8F958065F}.Release|x64.Build.0 = Release|Any CPU
{7FFF164E-CCD7-448F-8E2A-F0D8F958065F}.Release|x86.ActiveCfg = Release|Any CPU
{7FFF164E-CCD7-448F-8E2A-F0D8F958065F}.Release|x86.Build.0 = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|x64.ActiveCfg = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|x64.Build.0 = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|x86.ActiveCfg = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Debug|x86.Build.0 = Debug|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|Any CPU.Build.0 = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|x64.ActiveCfg = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|x64.Build.0 = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|x86.ActiveCfg = Release|Any CPU
{EB14C940-E3EA-41F3-9510-6CE73FABA41A}.Release|x86.Build.0 = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|x64.ActiveCfg = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|x64.Build.0 = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|x86.ActiveCfg = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Debug|x86.Build.0 = Debug|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|Any CPU.Build.0 = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|x64.ActiveCfg = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|x64.Build.0 = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|x86.ActiveCfg = Release|Any CPU
{D2208288-ACB6-477E-B8CF-C165A6F0155D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7FFF164E-CCD7-448F-8E2A-F0D8F958065F} = {11111111-1111-1111-1111-111111111111}
{EB14C940-E3EA-41F3-9510-6CE73FABA41A} = {11111111-1111-1111-1111-111111111111}
{D2208288-ACB6-477E-B8CF-C165A6F0155D} = {22222222-2222-2222-2222-222222222222}
EndGlobalSection
EndGlobal
2 changes: 2 additions & 0 deletions build/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<PackageVersion Include="Polly.Core" Version="8.4.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />

<!-- Sources / Formats / Destinations -->
Expand Down
22 changes: 11 additions & 11 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@
PRs pequenos e independentes, em ordem. Cada um fecha com testes verdes e fecha um critério de aceite (CA-n) da `docs/MVP-Spec.md`. Marque o checkbox ao concluir.

## PR 0 — Bootstrap do repositório
- [ ] `global.json`, `build/Directory.Build.props`, `build/Directory.Packages.props`, `.editorconfig`, `.gitignore`.
- [ ] `NeoReports.sln` com solution folders espelhando `src/ tests/ benchmarks/ samples/`.
- [ ] CI mínimo (`dotnet build` + `dotnet test` + `dotnet format --verify-no-changes`).
- [x] `global.json`, `build/Directory.Build.props`, `build/Directory.Packages.props`, `.editorconfig`, `.gitignore`.
- [x] `NeoReports.sln` com solution folders espelhando `src/ tests/ benchmarks/ samples/`.
- [x] CI mínimo (`dotnet build` + `dotnet test` + `dotnet format --verify-no-changes`).
- **Aceite:** `dotnet build` e `dotnet test` passam num repo vazio.

## PR 1 — NeoReports.Abstractions
- [ ] Tipos e interfaces typed-only conforme D9 (já esqueletados em `src/NeoReports.Abstractions/`).
- [ ] XML docs em inglês em tudo que é público.
- [x] Tipos e interfaces typed-only conforme D9 (já esqueletados em `src/NeoReports.Abstractions/`).
- [x] XML docs em inglês em tudo que é público.
- **Aceite:** compila multi-target (net8/net9), sem dependências além de `Logging.Abstractions`.
- **Depende de:** PR 0.

## PR 2 — NeoReports.Core: builder + pipeline batch
- [ ] Fluent builder genérico `ReportBuilder<TRow>` (`From/Map/Filter/Columns/To/UploadTo/Retry/OnFailure`).
- [ ] `IReportRegistry` + `AddReport<TRow>(...)` (DI).
- [ ] `ReportPipeline`: loop de batches, `StreamingToBatchAdapter`, projeção compilada `T → object?[]` na borda do writer (Expression-compiled getters por coluna).
- [ ] Integração Polly v8 (`ResiliencePipeline`) no read de batch.
- [ ] `IFailureStrategy`: `AbortReport`, `SkipBatchAndLog`; `ThresholdMonitor` (consecutivas/total/razão).
- **Aceite:** CA-1, CA-11, CA-12, CA-13, CA-14. Pipeline testado com source fake em memória.
- [x] Fluent builder genérico `ReportBuilder<TRow>` (`From`/`Filter`/`Columns`/`Column`/`To`/`UploadTo`/`Retry`/`OnFailure`; mapeamento via `From(source, map)` — ver D12).
- [x] `IReportRegistry` + `AddReport<TRow>(...)` (DI).
- [x] `ReportRunner`/pipeline: loop de batches, `TypedBatchReader` (adapta streaming → batches), projeção `T → object?[]` na borda do writer.
- [x] Integração Polly v8 (`ResiliencePipeline`) no read de batch.
- [x] `IFailureStrategy`: `AbortReport`, `SkipBatchAndLog`; threshold (consecutivas/total/razão) via `AbortIf` (ver D11).
- **Aceite:** CA-1, CA-11, CA-12, CA-13, CA-14. Pipeline testado com source fake em memória. ✅ 13 testes verdes.
- **Depende de:** PR 1.

## PR 3 — Sources.Sql + Formats.Csv + Destinations.Local (primeiro end-to-end)
Expand Down
27 changes: 27 additions & 0 deletions src/NeoReports.Core/Building/ColumnDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using NeoReports.Abstractions;

namespace NeoReports.Core.Building;

/// <summary>
/// A column declaration bound to a typed accessor. Pairs the public <see cref="ReportColumn"/>
/// metadata with a compiled getter that extracts the value from a <typeparamref name="T"/> row.
/// The getter boxes value types, but it is only invoked at the writer edge.
/// </summary>
/// <typeparam name="T">The row type the column reads from.</typeparam>
public sealed class ColumnDefinition<T>
{
/// <summary>Creates a column definition.</summary>
/// <param name="column">The public column metadata.</param>
/// <param name="getter">Compiled accessor that reads the column value from a row.</param>
public ColumnDefinition(ReportColumn column, Func<T, object?> getter)
{
Column = column;
Getter = getter;
}

/// <summary>The public column metadata (name, type, formatting hints).</summary>
public ReportColumn Column { get; }

/// <summary>Compiled accessor that reads the column value from a row.</summary>
public Func<T, object?> Getter { get; }
}
44 changes: 44 additions & 0 deletions src/NeoReports.Core/Building/FailureStrategyBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using NeoReports.Abstractions;
using NeoReports.Core.Resilience;

namespace NeoReports.Core.Building;

/// <summary>
/// Fluent configuration of what happens after a batch exhausts its retries. v1 supports
/// aborting the report or skipping the batch (with optional threshold-based escalation).
/// </summary>
public sealed class FailureStrategyBuilder
{
private bool _skip;
private Func<ThresholdContext, bool>? _abortIf;

/// <summary>Aborts the whole report on the first definitively failed batch.</summary>
public FailureStrategyBuilder AbortReport()
{
_skip = false;
return this;
}

/// <summary>Skips definitively failed batches and logs a warning (report becomes partial).</summary>
public FailureStrategyBuilder SkipBatchAndLog()
{
_skip = true;
return this;
}

/// <summary>
/// When skipping, escalates to an abort once the predicate is satisfied
/// (e.g. <c>t =&gt; t.ConsecutiveFailures(3)</c>).
/// </summary>
/// <param name="predicate">Threshold predicate evaluated on each failure.</param>
public FailureStrategyBuilder AbortIf(Func<ThresholdContext, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
_abortIf = predicate;
return this;
}

/// <summary>Builds the configured failure strategy. Defaults to aborting when unconfigured.</summary>
public IFailureStrategy Build() =>
_skip ? new SkipAndLogStrategy(_abortIf) : new AbortStrategy();
}
47 changes: 47 additions & 0 deletions src/NeoReports.Core/Building/OutputSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using NeoReports.Abstractions;

namespace NeoReports.Core.Building;

/// <summary>
/// A configured output: the writer factory that produces the format plus its options. Format
/// packages (CSV, XLSX) return one of these from their fluent entry points.
/// </summary>
public sealed class OutputSpec
{
/// <summary>Creates an output specification.</summary>
/// <param name="factory">Factory that creates the writer.</param>
/// <param name="options">Format-specific options; <c>null</c> is treated as empty.</param>
public OutputSpec(IWriterFactory factory, IReadOnlyDictionary<string, object?>? options = null)
{
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
Options = options ?? new Dictionary<string, object?>();
}

/// <summary>Factory that creates the writer for this output.</summary>
public IWriterFactory Factory { get; }

/// <summary>Format-specific options.</summary>
public IReadOnlyDictionary<string, object?> Options { get; }
}

/// <summary>
/// A configured destination: the destination factory plus its options. Destination packages
/// (Local, S3) return one of these from their fluent entry points.
/// </summary>
public sealed class DestinationSpec
{
/// <summary>Creates a destination specification.</summary>
/// <param name="factory">Factory that creates the destination.</param>
/// <param name="options">Destination-specific options; <c>null</c> is treated as empty.</param>
public DestinationSpec(IDestinationFactory factory, IReadOnlyDictionary<string, object?>? options = null)
{
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
Options = options ?? new Dictionary<string, object?>();
}

/// <summary>Factory that creates the destination.</summary>
public IDestinationFactory Factory { get; }

/// <summary>Destination-specific options.</summary>
public IReadOnlyDictionary<string, object?> Options { get; }
}
Loading
Loading