-
Notifications
You must be signed in to change notification settings - Fork 10
fix(state): keep maintenance bounded and retain forecast observations #1283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| Keep live storage responsive during archive and history maintenance. Bound write transactions, retain completed forecast observations for retry, and expose current storage and forecast health with failure history for diagnostics. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "log/slog" | ||
| "time" | ||
|
|
||
| "github.com/srcfl/ftw/go/internal/forecasting" | ||
| "github.com/srcfl/ftw/go/internal/state" | ||
| ) | ||
|
|
||
| // Keep a bounded backlog of completed intervals. The observer owns this queue; | ||
| // issuing forecasts and scoring them must not postpone the next measurement. | ||
| const maxPendingForecastObservations = 64 | ||
|
|
||
| type forecastObservationJob struct { | ||
| observation forecasting.Observation | ||
| site forecastSite | ||
| weather *state.ForecastPoint | ||
| away bool | ||
| archived bool | ||
| } | ||
|
|
||
| func (f *forecastTracker) runObservations(ctx context.Context) { | ||
| defer func() { | ||
| drain, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) | ||
| defer cancel() | ||
| f.flushObservations(drain) | ||
| if len(f.pendingObservations) > 0 { | ||
| slog.Error("forecast archive: shutdown observations still pending", "intervals", len(f.pendingObservations)) | ||
| } | ||
| }() | ||
| tick := time.NewTicker(10 * time.Second) | ||
| defer tick.Stop() | ||
| for { | ||
| select { | ||
| case <-ctx.Done(): | ||
| return | ||
| case <-tick.C: | ||
| f.observe(ctx) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Capture new intervals before disk work, but let a recovered backlog free | ||
| // capacity before rejecting the captured evidence. The same deadline bounds | ||
| // both drains; recovery never postpones the next observation indefinitely. | ||
| func (f *forecastTracker) acceptObservations(ctx context.Context, jobs []forecastObservationJob, flush func(context.Context)) { | ||
| if len(f.pendingObservations)+len(jobs) > maxPendingForecastObservations { | ||
| flush(ctx) | ||
| } | ||
| for _, job := range jobs { | ||
| f.enqueueObservation(job) | ||
| } | ||
| flush(ctx) | ||
| } | ||
|
|
||
| func (f *forecastTracker) enqueueObservation(job forecastObservationJob) { | ||
| if len(f.pendingObservations) == maxPendingForecastObservations { | ||
| f.mu.Lock() | ||
| f.observationOverflow = true | ||
| f.observationDrops++ | ||
| f.mu.Unlock() | ||
| slog.Error("forecast archive: observation queue full", "start_ms", job.observation.StartMS) | ||
| return | ||
|
Comment on lines
+59
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the queue contains 64 jobs and persistence recovers before the next observation tick, AGENTS.md reference: AGENTS.md:L22-L25 Useful? React with 👍 / 👎. |
||
| } | ||
| f.pendingObservations = append(f.pendingObservations, job) | ||
| } | ||
|
|
||
| func (f *forecastTracker) flushObservations(ctx context.Context) { | ||
| f.flushObservationsWith(ctx, f.store.SaveForecastObservation, f.updateObservation) | ||
| } | ||
|
|
||
| // Save and update retries keep the original evidence and captured features. | ||
| // A successful save followed by a failed model update skips saving on retry. | ||
| func (f *forecastTracker) flushObservationsWith(ctx context.Context, | ||
| save func(context.Context, forecasting.Observation) error, | ||
| update func(context.Context, forecastObservationJob) error) { | ||
| for len(f.pendingObservations) > 0 { | ||
| if ctx.Err() != nil { | ||
| return | ||
| } | ||
| job := &f.pendingObservations[0] | ||
| writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | ||
| phase := "observation_save_pending" | ||
| var err error | ||
| if !job.archived { | ||
| err = save(writeCtx, job.observation) | ||
| if err == nil { | ||
| job.archived = true | ||
| } | ||
| } | ||
| if err == nil { | ||
| phase = "model_save_pending" | ||
| err = update(writeCtx, *job) | ||
| } | ||
| if err != nil && writeCtx.Err() != nil { | ||
| err = errors.Join(err, writeCtx.Err()) | ||
| } | ||
| cancel() | ||
| f.mu.Lock() | ||
| if err != nil { | ||
| f.observationError = phase | ||
| } else { | ||
| f.observationError = "" | ||
| } | ||
| f.mu.Unlock() | ||
| if err != nil { | ||
| slog.Warn("forecast archive: observation retained for retry", "phase", phase, "start_ms", job.observation.StartMS, "err", err) | ||
| return | ||
| } | ||
| f.pendingObservations[0] = forecastObservationJob{} | ||
| f.pendingObservations = f.pendingObservations[1:] | ||
| f.requestScoring() | ||
| } | ||
| f.mu.Lock() | ||
| if f.observationOverflow { | ||
| slog.Info("forecast archive: observation queue recovered", "dropped_intervals", f.observationDrops) | ||
| } | ||
| f.observationOverflow = false | ||
| f.mu.Unlock() | ||
| } | ||
|
|
||
| func (f *forecastTracker) updateObservation(ctx context.Context, job forecastObservationJob) error { | ||
| if f.candidate == nil { | ||
| return nil | ||
| } | ||
| if f.configMu != nil { | ||
| f.configMu.RLock() | ||
| defer f.configMu.RUnlock() | ||
| } | ||
| f.learningMu.RLock() | ||
| defer f.learningMu.RUnlock() | ||
| current := f.site() | ||
| // Old configuration evidence remains in the archive, but must not switch | ||
| // the current model back to a previous identity when the queue recovers. | ||
| if current.IdentityPending || current.Revision != job.site.Revision { | ||
| return nil | ||
| } | ||
| site := f.learningSiteLocked(job.site) | ||
| return f.candidate.Update(ctx, site, job.observation, job.weather, job.away) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After any queue overflow, this flag is set permanently: neither a successful flush nor later healthy observations clear it. Consequently,
learningHealthreportsobservation_queue_fullfor the rest of the process lifetime even when the queue is empty, persistence is working, and training is current; track a current/full condition or clear the flag after recovery while retaining any historical failure separately.AGENTS.md reference: AGENTS.md:L22-L25
Useful? React with 👍 / 👎.