fix(telemetry): head-of-line starvation and consent-revocation leak
Third independent review. Both blockers reproduced against a real store before and after the fix. BLOCKER 1 — head-of-line starvation. The claim query is LIMIT 1, and a package already handled this pass was rejected AFTER the fetch, so _claim_next returned None and send_pending read that as 'queue empty'. Any row that sorts first and becomes eligible again mid-pass therefore terminated the pass. This is reachable normally: a 429 with a short Retry-After, or a pass outliving the 15-minute failure backoff (a legal pass runs ~1900s). Measured: 10 of 19 healthy packages silently dropped. The seen-set is now excluded IN SQL, so None genuinely means no eligible work. Same scenario now delivers 19 of 19. BLOCKER 2 — revoking consent leaked once it was re-granted. opt_in_period was write-once, so packages collected while the user had send: false still had period_start >= the ORIGINAL opt-in day; re-enabling released the whole refused window. Reproduced: 5 packages from a 5-day opted-out window transmitted on re-enable. Turning sending off now closes the consent window, and the next enabled pass opens a new one from that day. Recorded both in the setup wizard and in the sender itself, because config.yaml can be hand-edited where the wizard never sees it. Also: a send_attempts ceiling (a poisoned head row burned ~160 requests over 30 days, unbounded), _defer clamps to >= 1s so it cannot write a past deadline, and the dead skipped_not_due field is removed. Test-quality fixes, since vacuous tests have been the recurring problem: - the lease test asserted only 'in the future', passing for a 1s lease; it now requires the lease to outlast one package's worst legal case - test_shutdown_joins_the_send_thread grepped getsource for a method name — a change-detector AGENTS.md rejects — and is now behavioural - gzip determinism was unguarded: both retries in one pass compress in the same second, so removing mtime=0 was caught by nothing. Now compares output across a real second boundary. All five new regressions are mutation-verified: reintroducing each bug fails its test. The first attempt-ceiling test SURVIVED its mutation (the seeded row was excluded by another predicate) and was rewritten to drive the real loop. 251 tests pass. Staging E2E re-run: both packages 202.
This commit is contained in:
@@ -82,8 +82,19 @@ _FAILURE_BACKOFF_SECONDS = 15 * 60
|
||||
#: misconfiguration that resolves without the package changing.
|
||||
_PERMANENT_STATUSES = frozenset({400, 413})
|
||||
|
||||
#: Attempts after which a package is abandoned. Without a ceiling a
|
||||
#: permanently-poisoned row is retried until 30-day retention deletes it —
|
||||
#: measured at ~160 requests — which wastes the user's bandwidth and keeps a
|
||||
#: doomed package at the head of the queue.
|
||||
MAX_SEND_ATTEMPTS = 25
|
||||
|
||||
OPT_IN_PERIOD_KEY = "send_opt_in_period"
|
||||
|
||||
#: Set when sending is turned off, cleared by the next enabled pass (which
|
||||
#: also advances OPT_IN_PERIOD_KEY). This is what makes consent revocation
|
||||
#: permanent for the packages collected while it was off.
|
||||
SEND_REVOKED_KEY = "send_revoked"
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -100,7 +111,6 @@ class SendOutcome:
|
||||
sent: int = 0
|
||||
rejected: int = 0
|
||||
deferred: int = 0
|
||||
skipped_not_due: int = 0
|
||||
|
||||
|
||||
class _Response:
|
||||
@@ -159,25 +169,64 @@ def _retry_after_seconds(value: str | None, default: int) -> int:
|
||||
|
||||
|
||||
def opt_in_period(connection: sqlite3.Connection, *, now: datetime | None = None) -> str:
|
||||
"""Return the opt-in day (UTC date), recording it on first use.
|
||||
"""Return the day (UTC) from which packages may be sent.
|
||||
|
||||
Must run inside a write transaction. The value is written once and then
|
||||
never moves, so turning sending off and on again does not re-open the
|
||||
pre-consent backlog.
|
||||
Must run inside a write transaction.
|
||||
|
||||
This is the CURRENT consent window's start, not a permanent first-ever
|
||||
opt-in date. If the user previously turned sending off, ``record_revoked``
|
||||
stamps that; the next enabled pass advances the gate to the day sending
|
||||
resumed, so packages collected during the opted-out window are never
|
||||
transmitted. Without that advance, re-enabling would retroactively release
|
||||
the entire period the user had explicitly refused.
|
||||
"""
|
||||
row = connection.execute(
|
||||
"SELECT value FROM telemetry_state WHERE key = ?", (OPT_IN_PERIOD_KEY,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return str(row[0])
|
||||
today = (now or _utc_now()).date().isoformat()
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO telemetry_state(key, value) VALUES (?, ?)",
|
||||
(OPT_IN_PERIOD_KEY, today),
|
||||
)
|
||||
|
||||
revoked = _state_get(connection, SEND_REVOKED_KEY)
|
||||
if revoked:
|
||||
# Sending resumed after a revocation: the new window starts today.
|
||||
_state_set(connection, OPT_IN_PERIOD_KEY, today)
|
||||
connection.execute(
|
||||
"DELETE FROM telemetry_state WHERE key = ?", (SEND_REVOKED_KEY,)
|
||||
)
|
||||
return today
|
||||
|
||||
existing = _state_get(connection, OPT_IN_PERIOD_KEY)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
_state_set(connection, OPT_IN_PERIOD_KEY, today)
|
||||
return today
|
||||
|
||||
|
||||
def record_revoked(connection: sqlite3.Connection) -> None:
|
||||
"""Mark that sending was turned off, closing the current consent window.
|
||||
|
||||
Idempotent. The marker is only cleared by the next enabled pass, which
|
||||
also advances the gate — so any package collected between the two events
|
||||
stays local permanently.
|
||||
"""
|
||||
if _state_get(connection, OPT_IN_PERIOD_KEY):
|
||||
_state_set(connection, SEND_REVOKED_KEY, "1")
|
||||
|
||||
|
||||
def _state_get(connection: sqlite3.Connection, key: str) -> str | None:
|
||||
row = connection.execute(
|
||||
"SELECT value FROM telemetry_state WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return str(row[0]) if row is not None else None
|
||||
|
||||
|
||||
def _state_set(connection: sqlite3.Connection, key: str, value: str) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO telemetry_state(key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
class SharedMetricsSender:
|
||||
"""Sends exported packages, one bounded pass at a time."""
|
||||
|
||||
@@ -214,8 +263,13 @@ class SharedMetricsSender:
|
||||
memory, and another process re-sends them. Taking one row at a time
|
||||
keeps the lease covering only the package actually in flight.
|
||||
|
||||
``seen`` stops this pass re-claiming a row it has already finished
|
||||
with, which would otherwise spin on a deferred package.
|
||||
``seen`` holds packages this pass has already finished with. They are
|
||||
excluded IN SQL rather than by rejecting the fetched row: with
|
||||
``LIMIT 1``, returning None for an already-seen row would make the
|
||||
caller believe the queue was empty and abandon every healthy package
|
||||
behind it. A row can legitimately become eligible again mid-pass (a
|
||||
short Retry-After, or a pass that outlives the 15-minute failure
|
||||
backoff), so this is reachable in normal operation, not just in tests.
|
||||
"""
|
||||
with self._store._connection() as connection:
|
||||
with write_txn(connection):
|
||||
@@ -223,27 +277,29 @@ class SharedMetricsSender:
|
||||
stamp = _isoformat(now)
|
||||
lease_until = now + timedelta(seconds=_CLAIM_LEASE_SECONDS)
|
||||
|
||||
placeholders = ",".join("?" for _ in seen)
|
||||
exclusion = (
|
||||
f" AND package_id NOT IN ({placeholders})" if seen else ""
|
||||
)
|
||||
row = connection.execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT package_id, payload_json, sent_install_id
|
||||
FROM package_outbox
|
||||
WHERE exported_at IS NOT NULL
|
||||
AND (send_state IS NULL OR send_state = 'pending')
|
||||
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
|
||||
AND substr(period_start, 1, 10) >= ?
|
||||
AND send_attempts < ?
|
||||
{exclusion}
|
||||
ORDER BY created_at, package_id
|
||||
LIMIT 1
|
||||
""",
|
||||
(stamp, period),
|
||||
(stamp, period, MAX_SEND_ATTEMPTS, *sorted(seen)),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
package_id = str(row[0])
|
||||
if package_id in seen:
|
||||
# Already handled this pass; leave it for a later one.
|
||||
return None
|
||||
|
||||
derived = row[2]
|
||||
if not derived:
|
||||
derived = self._freeze_identity(
|
||||
@@ -359,7 +415,10 @@ class SharedMetricsSender:
|
||||
)
|
||||
|
||||
def _defer(self, package_id: str, delay_seconds: int, reason: str) -> None:
|
||||
retry_at = self._now().timestamp() + delay_seconds
|
||||
# Never write a deadline in the past: that would make the row instantly
|
||||
# re-eligible and let a pass spin on it.
|
||||
delay = max(1, int(delay_seconds))
|
||||
retry_at = self._now().timestamp() + delay
|
||||
self._mark(
|
||||
package_id,
|
||||
send_state="pending",
|
||||
@@ -454,9 +513,13 @@ class SharedMetricsSender:
|
||||
for _ in range(MAX_PACKAGES_PER_PASS):
|
||||
if not self._still_consented():
|
||||
# The user turned sending off while this pass was running.
|
||||
# Stop without transmitting anything further; unclaimed rows
|
||||
# stay pending and claimed-but-unsent rows expire naturally.
|
||||
# Stop without transmitting anything further, and close the
|
||||
# consent window so a later re-enable cannot release the
|
||||
# packages collected in the meantime. Recorded here as well as
|
||||
# in the setup wizard because config.yaml can be edited by
|
||||
# hand, which the wizard never sees.
|
||||
logger.info("Shared-metrics sending disabled mid-pass; stopping")
|
||||
self._record_revocation()
|
||||
break
|
||||
try:
|
||||
package = self._claim_next(self._now(), seen)
|
||||
@@ -488,6 +551,15 @@ class SharedMetricsSender:
|
||||
outcome.deferred += 1
|
||||
return outcome
|
||||
|
||||
def _record_revocation(self) -> None:
|
||||
"""Close the consent window after an observed revocation."""
|
||||
try:
|
||||
with self._store._connection() as connection:
|
||||
with write_txn(connection):
|
||||
record_revoked(connection)
|
||||
except Exception:
|
||||
logger.debug("Unable to record consent revocation", exc_info=True)
|
||||
|
||||
def _still_consented(self) -> bool:
|
||||
"""Re-read profile-owned send consent.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user