@@ -34,6 +34,7 @@ type forecastSiteConfig struct {
3434 required map [string ]bool // value identifies PV sources
3535 accepted forecastIdentityReceipt
3636 bindingDirty bool
37+ bindingRetryAt time.Time // a failed binding save waits until then
3738 configuredAt time.Time
3839 heatingPrior , ratedPV float64
3940 annualBase float64
@@ -49,6 +50,10 @@ type forecastIdentityReceipt struct {
4950
5051const forecastIdentityReceiptKey = "forecast/live_identity_v1"
5152
53+ // forecastBindingRetry spaces out binding saves after a failure. Each attempt
54+ // can wait seconds on SQLite, and the identity poll runs every second.
55+ const forecastBindingRetry = 30 * time .Second
56+
5257// forecastPipelinePolicy names how Core composes the forecast it plans with.
5358// The evaluation cohort keys on it rather than on the Core version, so error
5459// bands and baselines survive updates that leave forecasting alone. Bump it
@@ -137,6 +142,7 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat
137142 }
138143 weatherData , _ := json .Marshal (weather )
139144 weatherRevision := fmt .Sprintf ("%x" , sha256 .Sum256 (weatherData ))
145+ var weatherReceipt []byte
140146 s .mu .Lock ()
141147 for _ , old := range earlier {
142148 if err != nil || old == baseRevision || s .accepted .BaseRevision != old {
@@ -152,13 +158,10 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat
152158 if s .weatherRevision != weatherRevision || s .weatherSinceMS <= 0 {
153159 s .weatherRevision = weatherRevision
154160 s .weatherSinceMS = time .Now ().UnixMilli ()
155- receipt , _ : = json .Marshal (struct {
161+ weatherReceipt , _ = json .Marshal (struct {
156162 Revision string
157163 SinceMS int64
158164 }{weatherRevision , s .weatherSinceMS })
159- if err := s .store .SaveConfig ("forecast/weather_generation" , string (receipt )); err != nil {
160- slog .Warn ("forecast weather generation not saved" , "err" , err )
161- }
162165 }
163166 v .WeatherSinceMS = s .weatherSinceMS
164167 v .SiteID = s .value .SiteID
@@ -190,6 +193,12 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat
190193 }{weather , s .annualBase , s .annualIncludesHeating })
191194 s .priorRevision = fmt .Sprintf ("%x" , sha256 .Sum256 (priorData ))
192195 s .mu .Unlock ()
196+ // Save outside s.mu: the control tick reads Snapshot and must not wait on SQLite.
197+ if weatherReceipt != nil {
198+ if err := s .store .SaveConfig ("forecast/weather_generation" , string (weatherReceipt )); err != nil {
199+ slog .Warn ("forecast weather generation not saved" , "err" , err )
200+ }
201+ }
193202 s .RefreshIdentity (time .Now ())
194203 return previous != s .Snapshot ().Revision
195204}
@@ -198,10 +207,10 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat
198207// Serial/MAC identities are usable immediately. Endpoint fallback retains the
199208// existing device contract after a short init grace. A previously stronger
200209// identity must return before it can qualify again after restart.
201- // Caller serializes this with model rebinding under forecastConfigMu.
210+ // Caller serializes this with model rebinding under forecastConfigMu, so the
211+ // saves below, made outside s.mu, keep their order.
202212func (s * forecastSiteConfig ) RefreshIdentity (now time.Time ) bool {
203213 s .mu .Lock ()
204- defer s .mu .Unlock ()
205214 ids := make (map [string ]string , len (s .required ))
206215 pending , pvPending := false , false
207216 for name , isPV := range s .required {
@@ -250,12 +259,25 @@ func (s *forecastSiteConfig) RefreshIdentity(now time.Time) bool {
250259 s .accepted .LearningBaseRevision = learningBase
251260 }
252261 s .bindingDirty = true
253- if encoded , err := json .Marshal (s .accepted ); err == nil {
254- if err = s .store .SaveConfig (forecastIdentityReceiptKey , string (encoded )); err != nil {
255- slog .Warn ("forecast identity binding not saved" , "err" , err )
256- } else {
257- s .bindingDirty = false
258- }
262+ }
263+ var binding []byte
264+ if ! pending && s .bindingDirty && ! now .Before (s .bindingRetryAt ) {
265+ binding , _ = json .Marshal (s .accepted )
266+ }
267+ s .mu .Unlock ()
268+
269+ // Save outside s.mu: the control tick reads Snapshot and must not wait on SQLite.
270+ if binding != nil {
271+ err := s .store .SaveConfig (forecastIdentityReceiptKey , string (binding ))
272+ s .mu .Lock ()
273+ if err != nil {
274+ s .bindingRetryAt = now .Add (forecastBindingRetry )
275+ } else {
276+ s .bindingDirty = false
277+ }
278+ s .mu .Unlock ()
279+ if err != nil {
280+ slog .Warn ("forecast identity binding not saved" , "err" , err , "retry_in" , forecastBindingRetry )
259281 }
260282 }
261283 if changed {
0 commit comments