Skip to content

Memoize DeviceSettings reads in process memory#14894

Open
rtibbles wants to merge 1 commit into
learningequality:developfrom
rtibbles:device-settings-memo
Open

Memoize DeviceSettings reads in process memory#14894
rtibbles wants to merge 1 commit into
learningequality:developfrom
rtibbles:device-settings-memo

Conversation

@rtibbles

Copy link
Copy Markdown
Member

Summary

Memoizes device settings in process memory for 1s.

Nearly every request reads device settings, so even cached it means a disk read each time.

In-process memo means that under heavy load Kolibri does one disk read per second for it, not hundreds.

References

None - testing a range of performance optimizations showed this as a clear and easy winner.

Reviewer guidance

Load testing results (learner focused flow):

Online pod (Postgres, 200 users, 3 min):

  • With develop the pod reliably fails: ~14k requests, p99 69s - cascading login failure results in requests never happening.
  • This change alone prevents failure: ~23k requests (+63% throughput), p99 21s, and the flow completes.

Invalidation and test isolation:

  • save() clears the memo up front, so a failed full_clean() leaves nothing stale.
  • delete() clears it too, on both the model and the queryset.
  • The 1s TTL limits staleness from writes in other processes.
  • conftest.py's autouse fixture clears the memo alongside process_cache, to reset per test.

AI usage

Implemented with Claude Code from an approach I specified; I reviewed the code and tests and measured the results myself.

@github-actions github-actions Bot added DEV: backend Python, databases, networking, filesystem... SIZE: small labels Jun 28, 2026
@github-actions

github-actions Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

@rtibbles
rtibbles force-pushed the device-settings-memo branch from 4df3e73 to 2b5baf4 Compare June 28, 2026 18:05
@rtibbles
rtibbles marked this pull request as ready for review July 2, 2026 21:37
@rtibbles
rtibbles requested a review from rtibblesbot July 19, 2026 15:51
@rtibblesbot

rtibblesbot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

🔵 Review posted

Last updated: 2026-07-20 04:06 UTC

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #14894 adds a 1s in-process memo over process_cache for DeviceSettings reads. Invalidation on save/delete, the up-front clear before full_clean(), not caching misses, and the test-suite reset hooks (conftest + auth helpers) are all sound. CI passing; no UI files changed, so UI/manual-QA verification is not applicable.

No blocking issues. Findings:

  • suggestion (models.py:108) — the memo now hands the same mutable instance to every caller within the TTL, a behavior change from the copy-returning cache; interacts with the get→mutate→save pattern. See inline.
  • suggestion (test:46) — the save-invalidation test aliases the memoized instance, so it can't fail if invalidation is removed.
  • nitpick (test:38) — comment says "sqlite-backed" but the test cache is LocMem.

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran a phased review pipeline over the pull request diff:

  • Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
  • Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
  • Specialized frontend/backend review passes applied framework-specific lenses where those files changed
  • For UI changes: manual QA and an accessibility audit against a live dev server, when available
  • Checked CI status and linked issue acceptance criteria
  • Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence

model = None
model = _device_settings_memo.get()
if isinstance(model, DeviceSettings):
return model

@rtibblesbot rtibblesbot Jul 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

suggestion: The memo returns the same DeviceSettings object to every get() caller in the process for the TTL window, whereas process_cache (DiskCache) previously deserialized a fresh copy per call. This collides with the get→mutate-in-place→save pattern in device/utils.py (set_device_settings/provision_device): thread A mutates the shared instance via setattr before calling save() (which is where the memo clears), so a concurrent thread B's get_device_setting(...) can read uncommitted values that full_clean() may still reject. Low-frequency, and all current call sites are read-only or mutate-then-save, so it's safe today — but the invariant ("returned instance is shared; don't mutate without an immediate save()") is non-obvious. Either copy.copy(model) on set/get (preserves the read-savings goal with per-caller isolation) or document it on get().

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the memo now copy()s on both set and get, so each caller gets its own instance as DiskCache deserialization did. Not just theoretical: DeviceSettingsView.patch binds a DRF serializer to the returned instance and DeviceNameView.patch setattrs before save().

# Populate the memo, then change the setting through a fresh save.
DeviceSettings.objects.get()
ds = DeviceSettings.objects.get()
ds.language_id = "es"

@rtibblesbot rtibblesbot Jul 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

suggestion: ds here is the same object the memo holds (get() returns the memoized instance), so mutating ds.language_id mutates the memoized instance in place. With the _device_settings_memo.clear() removed from save() the memo still holds this now-"es" object, so line 48 passes anyway — the test can't fail on the invalidation it names. Change the value through a non-aliasing path, e.g. DeviceSettings(language_id="es").save() on a fresh instance, so a missing clear leaves the stale "en" and the test fails. (test_delete_invalidates_in_process_memo already fails correctly.)

DeviceSettings.objects.get()
DeviceSettings.objects.get()
# First read populates the in-process memo; the rest are served from it
# without touching the (sqlite-backed) process_cache.

@rtibblesbot rtibblesbot Jul 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

nitpick: Under test settings process_cache is a LocMemCache, not sqlite/DiskCache — the "(sqlite-backed)" parenthetical could mislead someone debugging this test. Drop it or say "the process_cache backend".

@rtibbles
rtibbles force-pushed the device-settings-memo branch from 2b5baf4 to 64cb1cc Compare July 20, 2026 01:38

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #14894 — all three prior findings resolved; one minor forward-looking suggestion below.

The memo now copies on both set() and get(), the save-invalidation test fetches via a non-aliasing filter(pk=1).first(), and the misleading "(sqlite-backed)" comment is gone. Clearing the memo before full_clean() in save() is a nice defensive touch — a ValidationError can't leave a stale entry.

  • suggestion (models.py) — copy() is shallow; noted inline as a future-safety guard for the shared extra_settings dict.
Prior-finding status

RESOLVED — models.py:111 — memo handed same mutable instance to callers (now copies on set/get)
RESOLVED — test_device_settings.py:47 — save-invalidation test aliased memoized instance (now non-aliasing filter().first())
RESOLVED — test_device_settings.py:38 — misleading "(sqlite-backed)" comment (removed)


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

Comment thread kolibri/core/device/models.py Outdated


class _DeviceSettingsMemo(object):
# Copies in and out so each caller gets its own instance, as deserializing

@rtibblesbot rtibblesbot Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

suggestion: copy() is a shallow copy, so the JSONField extra_settings dict is shared between the memo's stored instance and every returned copy. Fine today — all callers reassign whole fields via setattr then save() (which clears the memo). But a future caller that mutates instance.extra_settings[...] = x in place without saving would silently corrupt the memoized value (the old DiskCache path deep-copied via pickle, so it was immune). Worth a one-line note here, or copy.deepcopy, so the invariant survives a future edit.

The DeviceSettings singleton is read many times per request (e.g. by
the locale middleware), and each read of the sqlite-backed
process_cache is a measurable cost. Hold the loaded instance in
process memory for a short TTL on top of process_cache; local writes
clear the memo immediately and the TTL bounds staleness from writes in
other processes. Also collapse the previous `key in cache` plus
`cache.get` pair into a single get, halving the diskcache ops on the
memo-miss path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rtibbles
rtibbles force-pushed the device-settings-memo branch from 64cb1cc to 2b67d56 Compare July 20, 2026 03:47

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #14894 — delta re-review. All 4 prior findings resolved; the memo now deepcopys in and out so each caller's extra_settings is isolated, and the new tests lock in the isolation and save-invalidation behavior. One soft note on the post-save window (inline, no change required). CI pending; no UI files, so Phase 3 N/A.

Prior-finding status

RESOLVED — models.py:112 — memo handed same mutable instance to callers
RESOLVED — models.py — copy() shallow, shared extra_settings dict (now deepcopy)
RESOLVED — test_device_settings.py:57 — save-invalidation test aliased memoized instance
RESOLVED — test_device_settings.py:38 — misleading "(sqlite-backed)" comment


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

)

def save(self, *args, **kwargs):
# Clear up front so a failed full_clean leaves no stale memo behind.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: save() clears the memo here but only re-seats process_cache at the end. In a threaded worker, a concurrent get() landing in that window reads the pre-save value from process_cache and re-memoizes it with a fresh 1s TTL — so an already-committed write can be served stale from this process for up to the TTL, slightly stronger than the cross-process framing in the PR description. Bounded to 1s and always a valid DeviceSettings, so consistent with the accepted staleness tradeoff; no change required if 1s is acceptable (it almost certainly is). Sync workers are unaffected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we're not OK with 1s staleness, we shouldn't be doing this, so I think this is fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — that framing is exactly right. The window I described is just another facet of the same 1s staleness budget the design already accepts, so it stands or falls with that same decision. No change needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: small

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants