Don't load full right away

This commit is contained in:
Jack Kingsman
2026-03-10 14:39:40 -07:00
parent 97997e23e8
commit 738e0b9815
9 changed files with 166 additions and 24 deletions
+1 -1
View File
@@ -444,7 +444,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Enable periodic `get_msg()` fallback polling if radio events are not reliably surfacing messages |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` now means favorite contacts kept loaded on the radio for ACK support, not a recent-contact rotation pool. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
+1 -1
View File
@@ -251,7 +251,7 @@ Main tables:
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` now controls how many favorite contacts are kept loaded on the radio for ACK support. The app no longer rotates recent non-favorite contacts onto the radio during normal background maintenance.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
`app_settings` fields in active model:
- `max_radio_contacts`
+1 -1
View File
@@ -778,7 +778,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
Create app_settings table for persistent application preferences.
This table stores:
- max_radio_contacts: Max favorite contacts to keep on radio for DM ACKs
- max_radio_contacts: Configured radio contact capacity baseline for maintenance thresholds
- favorites: JSON array of favorite conversations [{type, id}, ...]
- auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact
- sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting
+4 -1
View File
@@ -515,7 +515,10 @@ class AppSettings(BaseModel):
max_radio_contacts: int = Field(
default=200,
description="Maximum favorite contacts to keep on radio for DM ACKs",
description=(
"Configured radio contact capacity used for maintenance thresholds; "
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
+69 -14
View File
@@ -11,6 +11,7 @@ don't work reliably.
import asyncio
import logging
import math
import time
from contextlib import asynccontextmanager
@@ -139,8 +140,57 @@ async def pause_polling():
# Background task handle
_sync_task: asyncio.Task | None = None
# Sync interval in seconds (10 minutes)
SYNC_INTERVAL = 600
# Periodic maintenance check interval in seconds (5 minutes)
SYNC_INTERVAL = 300
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
RADIO_CONTACT_REFILL_RATIO = 0.80
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts)
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
full_sync_trigger = max(
refill_target,
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
)
return refill_target, full_sync_trigger
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get()
capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.warning("Periodic sync occupancy check failed: %s", result)
return False
current_contacts = len(result.payload or {})
if current_contacts >= full_sync_trigger:
logger.info(
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
current_contacts,
capacity,
full_sync_trigger,
refill_target,
)
return True
logger.debug(
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
current_contacts,
capacity,
full_sync_trigger,
refill_target,
)
return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
@@ -311,9 +361,9 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
# Ensure default channels exist
await ensure_default_channels()
# Reload favorites back onto the radio immediately so they do not stay
# in on_radio=False limbo after offload. Pass mc directly since the
# caller already holds the radio operation lock (asyncio.Lock is not
# Reload favorites plus a working-set fill back onto the radio immediately
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
# since the caller already holds the radio operation lock (asyncio.Lock is not
# reentrant).
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
@@ -580,8 +630,8 @@ async def _periodic_sync_loop():
"periodic_sync",
blocking=False,
) as mc:
logger.debug("Running periodic radio sync")
await sync_and_offload_all(mc)
if await should_run_full_periodic_sync(mc):
await sync_and_offload_all(mc)
await sync_radio_time(mc)
except RadioOperationBusyError:
logger.debug("Skipping periodic sync: radio busy")
@@ -627,12 +677,14 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
2. Most recently interacted-with non-repeaters
3. Most recently advert-heard non-repeaters without interaction history
Contacts are loaded up to max_radio_contacts.
Favorite contacts are always reloaded first, up to the configured capacity.
Additional non-favorite fill stops at the refill target (80% of capacity).
Caller must hold the radio operation lock and pass a valid MeshCore instance.
"""
app_settings = await AppSettingsRepository.get()
max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
@@ -659,7 +711,7 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
if len(selected_contacts) >= max_contacts:
break
if len(selected_contacts) < max_contacts:
if len(selected_contacts) < refill_target:
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
limit=max_contacts
):
@@ -668,10 +720,10 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
continue
selected_keys.add(key)
selected_contacts.append(contact)
if len(selected_contacts) >= max_contacts:
if len(selected_contacts) >= refill_target:
break
if len(selected_contacts) < max_contacts:
if len(selected_contacts) < refill_target:
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
limit=max_contacts
):
@@ -680,13 +732,14 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
continue
selected_keys.add(key)
selected_contacts.append(contact)
if len(selected_contacts) >= max_contacts:
if len(selected_contacts) >= refill_target:
break
logger.debug(
"Selected %d contacts to sync (%d favorites, limit=%d)",
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
len(selected_contacts),
favorite_contacts_loaded,
refill_target,
max_contacts,
)
return await _load_contacts_to_radio(mc, selected_contacts)
@@ -811,7 +864,9 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
Load contacts to the radio for DM ACK support.
Fill order is favorites, then recently contacted non-repeaters,
then recently advert-heard non-repeaters until max_radio_contacts.
then recently advert-heard non-repeaters. Favorites are always reloaded
up to the configured capacity; additional non-favorite fill stops at the
80% refill target.
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
Args:
+4 -1
View File
@@ -18,7 +18,10 @@ class AppSettingsUpdate(BaseModel):
default=None,
ge=1,
le=1000,
description="Maximum favorite contacts to keep loaded on the radio for ACK support",
description=(
"Configured radio contact capacity used for maintenance thresholds and "
"background refill behavior"
),
)
auto_decrypt_dm_on_advert: bool | None = Field(
default=None,
@@ -575,7 +575,8 @@ export function SettingsRadioSection({
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts stay loaded on the radio for DM ACK support up to this limit (1-1000)
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
</div>
+1 -3
View File
@@ -184,9 +184,7 @@ describe('SettingsModal', () => {
openRadioSection();
expect(
screen.getByText(
/Favorite contacts stay loaded on the radio for DM ACK support up to this limit/i
)
screen.getByText(/Configured radio contact capacity/i)
).toBeInTheDocument();
});
+83 -1
View File
@@ -218,7 +218,7 @@ class TestSyncRecentContactsToRadio:
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
await AppSettingsRepository.update(
max_radio_contacts=4, favorites=[Favorite(type="contact", id=KEY_A)]
max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)]
)
mock_mc = MagicMock()
@@ -236,6 +236,33 @@ class TestSyncRecentContactsToRadio:
]
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
@pytest.mark.asyncio
async def test_favorites_can_exceed_non_favorite_refill_target(self, test_db):
"""Favorites are reloaded even when they exceed the 80% background refill target."""
favorite_keys = ["aa" * 32, "bb" * 32, "cc" * 32, "dd" * 32]
for index, key in enumerate(favorite_keys):
await _insert_contact(key, f"Favorite{index}", last_contacted=2000 - index)
await AppSettingsRepository.update(
max_radio_contacts=4,
favorites=[Favorite(type="contact", id=key) for key in favorite_keys],
)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 4
loaded_keys = [
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
]
assert loaded_keys == favorite_keys
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
@@ -1287,6 +1314,36 @@ class TestPeriodicAdvertLoopRaces:
class TestPeriodicSyncLoopRaces:
"""Regression tests for disconnect/reconnect race paths in _periodic_sync_loop."""
@pytest.mark.asyncio
async def test_should_run_full_periodic_sync_at_trigger_threshold(self, test_db):
"""Occupancy at 95% of configured capacity triggers a full offload/reload."""
from app.radio_sync import should_run_full_periodic_sync
await AppSettingsRepository.update(max_radio_contacts=100)
mock_mc = MagicMock()
mock_result = MagicMock()
mock_result.type = EventType.NEW_CONTACT
mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(95)}
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
assert await should_run_full_periodic_sync(mock_mc) is True
@pytest.mark.asyncio
async def test_should_skip_full_periodic_sync_below_trigger_threshold(self, test_db):
"""Occupancy below 95% of configured capacity does not trigger offload/reload."""
from app.radio_sync import should_run_full_periodic_sync
await AppSettingsRepository.update(max_radio_contacts=100)
mock_mc = MagicMock()
mock_result = MagicMock()
mock_result.type = EventType.NEW_CONTACT
mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(94)}
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
assert await should_run_full_periodic_sync(mock_mc) is False
@pytest.mark.asyncio
async def test_disconnect_race_between_precheck_and_lock(self):
"""RadioDisconnectedError between is_connected and radio_operation()
@@ -1299,12 +1356,14 @@ class TestPeriodicSyncLoopRaces:
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock) as mock_check,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_check.assert_not_called()
mock_sync.assert_not_called()
mock_time.assert_not_called()
assert len(sleep_calls) == 2
@@ -1321,6 +1380,7 @@ class TestPeriodicSyncLoopRaces:
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock) as mock_check,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
@@ -1329,6 +1389,7 @@ class TestPeriodicSyncLoopRaces:
lock.release()
mock_cleanup.assert_called_once()
mock_check.assert_not_called()
mock_sync.assert_not_called()
mock_time.assert_not_called()
@@ -1344,6 +1405,7 @@ class TestPeriodicSyncLoopRaces:
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock, return_value=True),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
@@ -1352,3 +1414,23 @@ class TestPeriodicSyncLoopRaces:
mock_cleanup.assert_called_once()
mock_sync.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc)
@pytest.mark.asyncio
async def test_skips_full_sync_below_threshold_but_still_syncs_time(self):
"""Periodic maintenance still does time sync when occupancy is below the trigger."""
rm, mock_mc = _make_connected_manager()
mock_sleep, _ = _sleep_controller(cancel_after=2)
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch("app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_sync.assert_not_called()
mock_time.assert_called_once_with(mock_mc)