Prevent output publication after cancellation
This commit is contained in:
@@ -28,13 +28,13 @@ func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Re
|
||||
}
|
||||
|
||||
type generationExecutor struct {
|
||||
called bool
|
||||
inspectErr error
|
||||
executeErr error
|
||||
respectCancellation bool
|
||||
validation promptexec.ValidationStatus
|
||||
rawOutput []byte
|
||||
failedPrompt string
|
||||
called bool
|
||||
inspectErr error
|
||||
executeErr error
|
||||
cancelBeforeReturn context.CancelFunc
|
||||
validation promptexec.ValidationStatus
|
||||
rawOutput []byte
|
||||
failedPrompt string
|
||||
}
|
||||
|
||||
func (e generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
|
||||
@@ -47,10 +47,7 @@ func (e generationExecutor) InspectPrompt(_ context.Context, id, version string)
|
||||
func (generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
|
||||
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
|
||||
}
|
||||
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
if e.respectCancellation && ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
|
||||
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
|
||||
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
|
||||
return nil, err
|
||||
@@ -70,6 +67,9 @@ func (e *generationExecutor) Execute(ctx context.Context, req promptexec.Execute
|
||||
if rawOutput == nil {
|
||||
rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
|
||||
}
|
||||
if e.cancelBeforeReturn != nil {
|
||||
e.cancelBeforeReturn()
|
||||
}
|
||||
return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", nil)}, nil
|
||||
}
|
||||
|
||||
@@ -137,25 +137,17 @@ func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
executor generationExecutor
|
||||
cancel bool
|
||||
}{
|
||||
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
|
||||
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
|
||||
{name: "cancellation", executor: generationExecutor{respectCancellation: true}, cancel: true},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if scenario.cancel {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
cancel()
|
||||
}
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(ctx, GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
|
||||
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
@@ -164,6 +156,45 @@ func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
const previousReport = "previous report"
|
||||
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||
Config: generationConfig(), Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
|
||||
})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "daily.md")
|
||||
const previousReport = "previous report"
|
||||
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
|
||||
defer cancel()
|
||||
bundle := generationBundle(t)
|
||||
result, err := GenerateDetailed(ctx, GenerateRequest{
|
||||
Config: generationConfig(), Report: ReportDaily,
|
||||
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
|
||||
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
|
||||
})
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
|
||||
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
|
||||
cfg := generationConfig()
|
||||
cfg.Notify.Distributor.Enabled = true
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
@@ -169,6 +170,9 @@ func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, erro
|
||||
if err != nil {
|
||||
return w.result, w.reportError("render template", err)
|
||||
}
|
||||
if err := publicationContextError(w.ctx); err != nil {
|
||||
return w.result, w.reportError("publish report", err)
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil {
|
||||
return w.result, err
|
||||
}
|
||||
@@ -195,6 +199,16 @@ func classifiedPromptError(operation string, err error) error {
|
||||
return promptexec.NewError(promptexec.Generation, operation, err)
|
||||
}
|
||||
|
||||
func publicationContextError(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
|
||||
}
|
||||
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptDebugWriteError(err error) error {
|
||||
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user