mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-06-21 18:45:10 +02:00
Don't load full right away
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user