Skip to content

Commit b1d2614

Browse files
Fox cloud: Fix stale cache settings (#4196)
1 parent 54761f4 commit b1d2614

3 files changed

Lines changed: 135 additions & 9 deletions

File tree

apps/predbat/fox.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,14 @@
5858
"minsoc": {"value": 10},
5959
}
6060

61+
# Bump this whenever the shape/derivation of device_settings entries changes (e.g. adding
62+
# range/unit/precision to schedule-derived settings) so a persisted cache from before that
63+
# change is detected as stale and forces one settings/scheduler refresh regardless of age,
64+
# instead of being reused as-is - potentially forever, since nothing else would ever correct it.
65+
FOX_SETTINGS_CACHE_VERSION = 2
66+
6167
# Storage cache keys for device data persisted between reboots
62-
FOX_CACHE_KEYS = ["device_list", "device_detail", "battery_charging_time", "device_settings", "device_settings_unavailable", "scheduler_state", "device_values", "device_production_month"]
68+
FOX_CACHE_KEYS = ["device_list", "device_detail", "battery_charging_time", "device_settings", "device_settings_unavailable", "device_settings_version", "scheduler_state", "device_values", "device_production_month"]
6369

6470
# Maximum age (minutes) of cached data before an API refresh is triggered
6571
FOX_REFRESH_STATIC = 24 * 60 # Device list, detail and battery charge times rarely change
@@ -344,6 +350,10 @@ def initialize(self, key, automatic, automatic_ignore_pv=False, inverter_sn=None
344350
# {deviceSN: [key_lower, ...]} of settings the device has reported as unsupported (errno
345351
# 42015/44096), so they are never polled or written to again
346352
self.device_settings_unavailable = {}
353+
# Version of the persisted device_settings cache actually on disk; 0 (never matches
354+
# FOX_SETTINGS_CACHE_VERSION) until load_cached_data() loads a real value, so a fresh
355+
# install/first-ever poll is treated the same as a stale cache - both force one refresh
356+
self.device_settings_version = 0
347357
# Set within request_get_func for the duration of a single request_get() call, so callers
348358
# can tell an "unsupported" failure (permanent) apart from a transient one
349359
self.last_unsupported = False
@@ -473,8 +483,14 @@ async def run(self, seconds, first):
473483
if sn:
474484
await self.get_device_history(sn)
475485

476-
# Device settings and scheduler - refresh based on age
477-
settings_refresh = self._needs_refresh("device_settings", FOX_REFRESH_SETTINGS)
486+
# Device settings and scheduler - refresh based on age. Also force a refresh, regardless
487+
# of age, when the persisted cache predates FOX_SETTINGS_CACHE_VERSION - a one-time
488+
# self-heal after a code update changes how settings are derived/shaped (e.g. adding
489+
# range/unit/precision), so a customer isn't stuck reusing a stale-shaped cached value
490+
# for up to FOX_REFRESH_SETTINGS. Once refreshed, the version is saved and this stops
491+
# firing - restarts do not otherwise force a refresh, to avoid hammering the API.
492+
stale_cache_version = self.device_settings_version != FOX_SETTINGS_CACHE_VERSION
493+
settings_refresh = stale_cache_version or self._needs_refresh("device_settings", FOX_REFRESH_SETTINGS)
478494
if settings_refresh:
479495
settings_updated = False
480496
scheduler_updated = False
@@ -486,8 +502,17 @@ async def run(self, seconds, first):
486502
settings_updated = True
487503
if await self.get_scheduler(sn) is not None:
488504
scheduler_updated = True
489-
if settings_updated:
505+
if settings_updated or scheduler_updated:
506+
# update_settings_from_schedule() (called from get_scheduler()) mutates
507+
# device_settings too, so this must save on scheduler_updated as well - not
508+
# just settings_updated - or a schedule-derived upgrade is lost on restart
490509
await self._save_cache("device_settings", self.device_settings)
510+
if stale_cache_version and scheduler_updated:
511+
# Only get_scheduler() -> update_settings_from_schedule() can actually produce
512+
# the upgraded (range/unit/precision) shape this version tracks, so the version
513+
# must not be marked current on settings_updated alone
514+
self.device_settings_version = FOX_SETTINGS_CACHE_VERSION
515+
await self._save_cache("device_settings_version", FOX_SETTINGS_CACHE_VERSION)
491516
if self.device_settings_unavailable:
492517
await self._save_cache("device_settings_unavailable", self.device_settings_unavailable)
493518
if scheduler_updated:
@@ -621,6 +646,10 @@ async def load_cached_data(self):
621646
if device_settings_unavailable is not None:
622647
self.device_settings_unavailable = device_settings_unavailable
623648

649+
device_settings_version = await self._load_cache("device_settings_version")
650+
if device_settings_version is not None:
651+
self.device_settings_version = device_settings_version
652+
624653
scheduler_state = await self._load_cache("scheduler_state")
625654
if isinstance(scheduler_state, dict):
626655
self.device_scheduler = scheduler_state.get("scheduler", {})

apps/predbat/predbat.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import pytz
3636
import asyncio
3737

38-
THIS_VERSION = "v8.44.3"
38+
THIS_VERSION = "v8.44.4"
3939

4040
from download import predbat_update_move, predbat_update_download, check_install, DEFAULT_PREDBAT_REPOSITORY
4141
from const import MINUTE_WATT

apps/predbat/tests/test_fox_api.py

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import aiohttp
1313
import json
1414
from unittest.mock import MagicMock, patch, AsyncMock
15-
from fox import validate_schedule, minutes_to_schedule_time, end_minute_inclusive_to_exclusive, FoxAPI, schedules_are_equal, FOX_CACHE_KEYS, FOX_REFRESH_SETTINGS, FOX_REFRESH_REALTIME, OPTIONS_WORK_MODE
15+
from fox import validate_schedule, minutes_to_schedule_time, end_minute_inclusive_to_exclusive, FoxAPI, schedules_are_equal, FOX_CACHE_KEYS, FOX_REFRESH_SETTINGS, FOX_REFRESH_REALTIME, OPTIONS_WORK_MODE, FOX_SETTINGS_CACHE_VERSION
1616
from tests.test_infra import run_async, create_aiohttp_mock_response, create_aiohttp_mock_session
1717

1818

@@ -67,6 +67,9 @@ def __init__(self):
6767
self.device_values = {}
6868
self.device_settings = {}
6969
self.device_settings_unavailable = {}
70+
# Default to "current" so existing tests aren't affected by the stale-cache-version
71+
# self-heal check; tests exercising that mechanism explicitly set an older value
72+
self.device_settings_version = FOX_SETTINGS_CACHE_VERSION
7073
self.last_unsupported = False
7174
self.device_production_month = {}
7275
self.device_production_year = {}
@@ -4392,6 +4395,94 @@ def test_run_settings_refresh_on_age(my_predbat):
43924395
return False
43934396

43944397

4398+
def test_run_settings_refresh_forced_by_stale_cache_version(my_predbat):
4399+
"""
4400+
Test run() forces a settings/scheduler refresh, regardless of cache age, when the
4401+
persisted device_settings_version predates FOX_SETTINGS_CACHE_VERSION - a one-time
4402+
self-heal so a customer isn't stuck reusing a stale-shaped cached setting (e.g. one
4403+
missing range/unit/precision from before that shape existed) for up to FOX_REFRESH_SETTINGS
4404+
after every restart. Also verifies the version is persisted afterwards so a subsequent,
4405+
still-fresh run does NOT force another refresh.
4406+
"""
4407+
print(" - test_run_settings_refresh_forced_by_stale_cache_version")
4408+
4409+
from datetime import datetime, timezone
4410+
4411+
fox = MockFoxAPIWithRunTracking()
4412+
fox.device_list = [{"deviceSN": "TEST123"}]
4413+
fox.device_settings_version = 0 # Simulates a cache persisted before this version existed
4414+
4415+
# Everything else, including device_settings itself, is freshly updated
4416+
now = datetime.now(timezone.utc)
4417+
for key in FOX_CACHE_KEYS:
4418+
fox.data_age[key] = now
4419+
4420+
result = run_async(fox.run(0, first=False))
4421+
4422+
assert result == True
4423+
# Forced despite a fresh device_settings cache age, because the version is stale
4424+
assert "get_device_settings:TEST123" in fox.method_calls
4425+
assert "get_scheduler:TEST123" in fox.method_calls
4426+
# The version must be bumped to current so it stops forcing a refresh going forward
4427+
assert fox.device_settings_version == FOX_SETTINGS_CACHE_VERSION
4428+
4429+
# A second run, still with a fresh cache age, must NOT force another refresh now that the
4430+
# version matches - otherwise every restart would keep hammering the API forever
4431+
fox.method_calls = []
4432+
fox.data_age["device_settings"] = now
4433+
result2 = run_async(fox.run(0, first=False))
4434+
assert result2 == True
4435+
assert "get_device_settings:TEST123" not in fox.method_calls
4436+
4437+
return False
4438+
4439+
4440+
def test_run_stale_cache_version_not_cleared_without_scheduler_success(my_predbat):
4441+
"""
4442+
Regression guard: the stale cache version must only clear once get_scheduler() (via
4443+
update_settings_from_schedule()) has actually had a chance to produce the upgraded shape -
4444+
not merely because get_device_settings() succeeded. Otherwise a cycle where settings
4445+
succeed but the scheduler read fails would wrongly mark the migration "done", permanently
4446+
losing the retry and leaving the customer stuck with the stale-shaped cached value.
4447+
"""
4448+
print(" - test_run_stale_cache_version_not_cleared_without_scheduler_success")
4449+
4450+
from datetime import datetime, timezone
4451+
4452+
class MockFoxAPISchedulerFails(MockFoxAPIWithRunTracking):
4453+
async def get_scheduler(self, deviceSN):
4454+
self.method_calls.append(f"get_scheduler:{deviceSN}")
4455+
return None # Simulates a failed scheduler read this cycle
4456+
4457+
fox = MockFoxAPISchedulerFails()
4458+
fox.device_list = [{"deviceSN": "TEST123"}]
4459+
fox.device_settings_version = 0
4460+
4461+
now = datetime.now(timezone.utc)
4462+
for key in FOX_CACHE_KEYS:
4463+
fox.data_age[key] = now
4464+
4465+
result = run_async(fox.run(0, first=False))
4466+
4467+
assert result == True
4468+
# Settings still succeeded and get attempted every cycle while the version stays stale
4469+
assert "get_device_settings:TEST123" in fox.method_calls
4470+
assert "get_scheduler:TEST123" in fox.method_calls
4471+
# Version must remain stale - the scheduler read (and any range upgrade it would apply)
4472+
# never actually succeeded
4473+
assert fox.device_settings_version == 0
4474+
4475+
# A second run, still with a fresh device_settings cache age, must keep retrying rather
4476+
# than silently giving up because the version never cleared
4477+
fox.method_calls = []
4478+
result2 = run_async(fox.run(0, first=False))
4479+
assert result2 == True
4480+
assert "get_device_settings:TEST123" in fox.method_calls
4481+
assert "get_scheduler:TEST123" in fox.method_calls
4482+
4483+
return False
4484+
4485+
43954486
def test_run_realtime_refresh_after_cache_expires(my_predbat):
43964487
"""
43974488
Test run() leaves fresh real-time data alone but re-fetches it once the cache expires,
@@ -4458,15 +4549,17 @@ def test_run_device_list_failure_does_not_mark_cache_fresh(my_predbat):
44584549
def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat):
44594550
"""
44604551
Test run() always re-fetches the device list on first start, even when the cached data
4461-
is still fresh, so a new inverter or changed serial number is picked up. Device detail
4462-
and all other age-gated categories are skipped while the cache is fresh.
4552+
is still fresh, so a new inverter or changed serial number is picked up. Device detail
4553+
and all other age-gated categories are skipped while the cache is fresh and the settings
4554+
cache version is current.
44634555
"""
44644556
print(" - test_run_first_refreshes_device_list_despite_fresh_cache")
44654557

44664558
from datetime import datetime, timezone
44674559

44684560
fox = MockFoxAPIWithRunTracking()
44694561
fox.device_list = [{"deviceSN": "TEST123"}]
4562+
fox.device_settings_version = FOX_SETTINGS_CACHE_VERSION
44704563

44714564
# Mark every cache category as freshly updated
44724565
now = datetime.now(timezone.utc)
@@ -4478,7 +4571,8 @@ def test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat):
44784571
assert result == True
44794572
# Device list must always refresh on first start regardless of cache age
44804573
assert "get_device_list" in fox.method_calls
4481-
# Age-based categories with fresh data should NOT be re-fetched on first start
4574+
# Age-based categories with fresh data and a current settings cache version should NOT be
4575+
# re-fetched on first start
44824576
assert "get_device_detail:TEST123" not in fox.method_calls
44834577
assert "get_real_time_data:TEST123" not in fox.method_calls
44844578
assert "get_device_settings:TEST123" not in fox.method_calls
@@ -4536,6 +4630,7 @@ def test_run_unchanged_device_list_preserves_cache(my_predbat):
45364630

45374631
fox = MockFoxAPIWithRunTracking()
45384632
fox.device_list = [{"deviceSN": "TEST123"}]
4633+
fox.device_settings_version = FOX_SETTINGS_CACHE_VERSION
45394634
now = datetime.now(timezone.utc)
45404635
for key in FOX_CACHE_KEYS:
45414636
fox.data_age[key] = now
@@ -6735,6 +6830,8 @@ def run_fox_api_tests(my_predbat):
67356830
failed |= test_run_first_call_no_devices(my_predbat)
67366831
failed |= test_run_subsequent_call(my_predbat)
67376832
failed |= test_run_settings_refresh_on_age(my_predbat)
6833+
failed |= test_run_settings_refresh_forced_by_stale_cache_version(my_predbat)
6834+
failed |= test_run_stale_cache_version_not_cleared_without_scheduler_success(my_predbat)
67386835
failed |= test_run_realtime_refresh_after_cache_expires(my_predbat)
67396836
failed |= test_run_device_list_failure_does_not_mark_cache_fresh(my_predbat)
67406837
failed |= test_run_first_refreshes_device_list_despite_fresh_cache(my_predbat)

0 commit comments

Comments
 (0)