2026-06-17 20:44:34 +00:00
package app
import (
2026-06-17 21:00:03 +00:00
"context"
2026-06-17 20:44:34 +00:00
"fmt"
2026-08-01 19:33:12 +00:00
"path/filepath"
2026-06-17 20:44:34 +00:00
"time"
2026-06-17 20:48:38 +00:00
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
2026-06-17 20:44:34 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
2026-06-17 20:48:38 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
2026-06-17 20:44:34 +00:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const runIDTimestampLayout = "20060102T150405.000000000Z"
type batchNotificationIdentity struct {
PipelineID string
BundleID string
IdempotencyKey string
}
2026-06-17 20:48:38 +00:00
type batchNotificationRequest struct {
Batch BatchKind
RunID string
PipelineID string
BundleID string
IdempotencyKey string
Files [ ] batchNotificationFile
IncludedReports [ ] BatchNotificationReport
CreatedAt time . Time
}
type batchNotificationFile struct {
ReportID report . ID
RunID string
SourcePath string
BundlePath string
}
2026-06-17 21:00:03 +00:00
type batchNotifier interface {
NotifyBatch ( context . Context , batchNotificationRequest ) ( * NotificationResult , error )
}
2026-08-13 13:52:16 +00:00
type batchNotificationInput struct {
ctx context . Context
cancellation error
cfg config . Config
batch BatchKind
runID string
startedAt time . Time
result * BatchResult
planned [ ] plannedBatchReport
notifier Notifier
}
2026-06-17 20:44:34 +00:00
func batchRunID ( startedAt time . Time , batch BatchKind ) string {
return startedAt . UTC ( ) . Format ( runIDTimestampLayout ) + "_" + string ( batch )
}
2026-08-13 13:52:16 +00:00
func notifyBatch ( input batchNotificationInput ) * BatchNotificationResult {
if ! input . cfg . Notify . Distributor . Enabled {
2026-08-02 00:15:41 +00:00
return nil
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
if ! input . cfg . Notify . Distributor . Batch . Enabled {
2026-08-02 00:15:41 +00:00
return nil
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
if input . result == nil {
2026-08-02 00:15:41 +00:00
return failedBatchNotificationResult ( batchNotificationRequest { } , fmt . Errorf ( "batch result is required" ) )
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
if input . cancellation != nil || batchContextCancellationCause ( input . ctx ) != nil || input . result . Canceled > 0 {
2026-08-13 03:02:59 +00:00
return & BatchNotificationResult {
Status : "skipped" ,
Reason : "batch canceled" ,
}
}
2026-08-13 13:52:16 +00:00
if input . result . Failed > 0 {
2026-06-17 21:00:03 +00:00
return & BatchNotificationResult {
Status : "skipped" ,
Reason : "one or more reports failed" ,
2026-08-02 00:15:41 +00:00
}
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
req , err := buildBatchNotificationRequest ( input . cfg , input . batch , input . runID , input . startedAt , input . result . Reports , input . planned )
2026-06-17 21:00:03 +00:00
if err != nil {
2026-08-02 00:15:41 +00:00
return failedBatchNotificationResult ( batchNotificationRequest { } , err )
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
batchNotifier , err := resolveBatchNotifier ( input . cfg , input . notifier )
2026-06-17 21:00:03 +00:00
if err != nil {
2026-08-02 00:15:41 +00:00
return failedBatchNotificationResult ( req , err )
2026-06-17 21:00:03 +00:00
}
2026-08-13 13:52:16 +00:00
notification , notifyErr := batchNotifier . NotifyBatch ( input . ctx , req )
2026-06-17 21:00:03 +00:00
wrappedErr := notifyErr
if notifyErr != nil {
2026-08-13 13:52:16 +00:00
wrappedErr = fmt . Errorf ( "notify batch %q run %q bundle %q: %w" , input . batch , input . runID , req . BundleID , notifyErr )
2026-06-17 21:00:03 +00:00
}
2026-08-01 19:40:51 +00:00
batchResult := batchNotificationResult ( req , notification )
2026-06-17 21:00:03 +00:00
if wrappedErr != nil {
batchResult . Status = "failed"
2026-08-13 02:55:25 +00:00
batchResult . Error = safeDistributorNotificationFailure ( wrappedErr )
2026-08-02 00:15:41 +00:00
return batchResult
2026-06-17 21:00:03 +00:00
}
2026-08-02 00:15:41 +00:00
return batchResult
2026-06-17 21:00:03 +00:00
}
func resolveBatchNotifier ( cfg config . Config , notifier Notifier ) ( batchNotifier , error ) {
if notifier != nil {
if batchNotifier , ok := notifier . ( batchNotifier ) ; ok {
return batchNotifier , nil
}
return nil , fmt . Errorf ( "batch distributor notifier is required" )
}
return distributorNotifier {
client : distributoradapter . New ( cfg . Notify . Distributor ) ,
} , nil
}
2026-06-17 20:48:38 +00:00
func buildBatchNotificationRequest ( cfg config . Config , batch BatchKind , runID string , startedAt time . Time , reports [ ] BatchReportResult , planned [ ] plannedBatchReport ) ( batchNotificationRequest , error ) {
if len ( reports ) == 0 {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification requires at least one report" )
}
identity , err := renderBatchNotificationIdentity ( cfg , batch , runID , startedAt )
if err != nil {
return batchNotificationRequest { } , err
}
if identity . PipelineID == "" {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification pipeline id is required" )
}
if identity . BundleID == "" {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification bundle id is required" )
}
if identity . IdempotencyKey == "" {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification idempotency key is required for bundle %q" , identity . BundleID )
}
plannedByRunID , err := plannedReportsByRunID ( planned )
if err != nil {
return batchNotificationRequest { } , err
}
req := batchNotificationRequest {
Batch : batch ,
RunID : runID ,
PipelineID : identity . PipelineID ,
BundleID : identity . BundleID ,
IdempotencyKey : identity . IdempotencyKey ,
CreatedAt : startedAt ,
}
seenBundlePaths := map [ string ] batchNotificationFile { }
for _ , item := range reports {
plannedReport , ok := plannedByRunID [ item . RunID ]
if ! ok {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification report %q run %q has no matching planned report" , item . ReportID , item . RunID )
}
if item . ReportID != plannedReport . Resolved . Definition . ID {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification report %q run %q does not match planned report %q" , item . ReportID , item . RunID , plannedReport . Resolved . Definition . ID )
}
2026-08-01 19:33:12 +00:00
if item . OutputPath == "" {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification report %q run %q is missing output path" , item . ReportID , item . RunID )
2026-06-17 20:48:38 +00:00
}
2026-08-01 19:33:12 +00:00
values , err := distributorTemplateValuesForReport ( cfg , plannedReport . Resolved , item . RunID , filepath . Base ( item . OutputPath ) )
2026-06-17 20:48:38 +00:00
if err != nil {
2026-08-01 19:33:12 +00:00
return batchNotificationRequest { } , fmt . Errorf ( "batch notification report %q run %q source path %q: %w" , item . ReportID , item . RunID , item . OutputPath , err )
2026-06-17 20:48:38 +00:00
}
2026-08-01 19:33:12 +00:00
bundlePaths , err := renderDistributorReportBundlePaths ( cfg , plannedReport . Resolved , item . RunID , item . OutputPath , values )
2026-06-17 20:48:38 +00:00
if err != nil {
2026-06-20 02:55:06 +00:00
return batchNotificationRequest { } , err
2026-06-17 20:48:38 +00:00
}
included := BatchNotificationReport {
ReportID : item . ReportID ,
RunID : item . RunID ,
2026-08-01 19:33:12 +00:00
SourcePath : item . OutputPath ,
2026-06-17 20:48:38 +00:00
BundlePaths : append ( [ ] string ( nil ) , bundlePaths ... ) ,
}
for _ , bundlePath := range bundlePaths {
file := batchNotificationFile {
ReportID : item . ReportID ,
RunID : item . RunID ,
2026-08-01 19:33:12 +00:00
SourcePath : item . OutputPath ,
2026-06-17 20:48:38 +00:00
BundlePath : bundlePath ,
}
if previous , ok := seenBundlePaths [ bundlePath ] ; ok {
2026-08-01 19:33:12 +00:00
return batchNotificationRequest { } , fmt . Errorf ( "batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q" , bundlePath , item . ReportID , item . RunID , item . OutputPath , previous . ReportID , previous . RunID , previous . SourcePath )
2026-06-17 20:48:38 +00:00
}
seenBundlePaths [ bundlePath ] = file
req . Files = append ( req . Files , file )
}
req . IncludedReports = append ( req . IncludedReports , included )
}
if len ( req . Files ) == 0 {
return batchNotificationRequest { } , fmt . Errorf ( "batch notification requires at least one file mapping" )
}
return req , nil
}
func plannedReportsByRunID ( planned [ ] plannedBatchReport ) ( map [ string ] plannedBatchReport , error ) {
byRunID := make ( map [ string ] plannedBatchReport , len ( planned ) )
for _ , item := range planned {
runID := item . Resolved . Metadata ( ) . RunID
if runID == "" {
return nil , fmt . Errorf ( "planned report %q has empty run id" , item . Resolved . Definition . ID )
}
if previous , ok := byRunID [ runID ] ; ok {
return nil , fmt . Errorf ( "planned reports %q and %q share run id %q" , previous . Resolved . Definition . ID , item . Resolved . Definition . ID , runID )
}
byRunID [ runID ] = item
}
return byRunID , nil
}
func batchDistributorUploadRequest ( req batchNotificationRequest ) distributoradapter . UploadRequest {
files := make ( [ ] distributoradapter . UploadFile , 0 , len ( req . Files ) )
for _ , file := range req . Files {
files = append ( files , distributoradapter . UploadFile {
SourcePath : file . SourcePath ,
BundlePath : file . BundlePath ,
} )
}
return distributoradapter . UploadRequest {
PipelineID : req . PipelineID ,
BundleID : req . BundleID ,
IdempotencyKey : req . IdempotencyKey ,
Files : files ,
CreatedAt : req . CreatedAt ,
}
}
2026-08-01 19:40:51 +00:00
func batchNotificationResult ( req batchNotificationRequest , result * NotificationResult ) * BatchNotificationResult {
2026-06-17 21:00:03 +00:00
notification := & BatchNotificationResult {
Status : "unknown" ,
PipelineID : req . PipelineID ,
BundleID : req . BundleID ,
IdempotencyKey : req . IdempotencyKey ,
IncludedReports : append ( [ ] BatchNotificationReport ( nil ) , req . IncludedReports ... ) ,
}
if result != nil {
notification . Status = result . Status
notification . RunID = result . RunID
if result . PipelineID != "" {
notification . PipelineID = result . PipelineID
}
if result . BundleID != "" {
notification . BundleID = result . BundleID
}
if result . IdempotencyKey != "" {
notification . IdempotencyKey = result . IdempotencyKey
}
if result . Error != "" {
2026-08-13 02:55:25 +00:00
notification . Error = safeDistributorRunError ( result . Error )
2026-06-17 21:00:03 +00:00
}
}
if notification . Status == "" {
notification . Status = "unknown"
}
return notification
}
2026-08-01 19:40:51 +00:00
func failedBatchNotificationResult ( req batchNotificationRequest , err error ) * BatchNotificationResult {
notification := batchNotificationResult ( req , nil )
2026-06-17 21:00:03 +00:00
notification . Status = "failed"
if err != nil {
2026-08-13 02:55:25 +00:00
notification . Error = safeDistributorNotificationFailure ( err )
2026-06-17 21:00:03 +00:00
}
return notification
}
2026-08-13 02:55:25 +00:00
func safeDistributorNotificationFailure ( err error ) string {
if err == nil {
return ""
}
return "distributor notification failed"
}
2026-06-17 20:44:34 +00:00
func renderBatchNotificationIdentity ( cfg config . Config , batch BatchKind , runID string , startedAt time . Time ) ( batchNotificationIdentity , error ) {
values , err := batchNotificationTemplateValues ( cfg , batch , runID , startedAt )
if err != nil {
return batchNotificationIdentity { } , err
}
bundleID , err := config . RenderDistributorBatchBundleID ( cfg . Notify . Distributor . Batch . BundleIDTemplate , values )
if err != nil {
return batchNotificationIdentity { } , err
}
values . BundleID = bundleID
pipelineID , err := config . RenderDistributorBatchPipelineID ( cfg . Notify . Distributor . Batch . PipelineIDTemplate , values )
if err != nil {
return batchNotificationIdentity { } , err
}
idempotencyKey , err := config . RenderDistributorBatchIdempotencyKey ( cfg . Notify . Distributor . Batch . IdempotencyKeyTemplate , values )
if err != nil {
return batchNotificationIdentity { } , err
}
return batchNotificationIdentity {
PipelineID : pipelineID ,
BundleID : bundleID ,
IdempotencyKey : idempotencyKey ,
} , nil
}
func batchNotificationTemplateValues ( cfg config . Config , batch BatchKind , runID string , startedAt time . Time ) ( config . DistributorBatchTemplateValues , error ) {
location , err := timeutil . LoadLocation ( cfg . WeatherAPI . Timezone )
if err != nil {
return config . DistributorBatchTemplateValues { } , fmt . Errorf ( "load batch notification timezone: %w" , err )
}
return config . DistributorBatchTemplateValues {
LocationID : cfg . Location . ID ,
Batch : string ( batch ) ,
BatchRunID : runID ,
BatchStartedDate : startedAt . In ( location ) . Format ( timeutil . DateLayout ) ,
} , nil
}