Dashboard figures: the cache key leaves out reminders, interviews, the clock and the zone, so they contradict Gone quiet #396
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#396
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading whatbuild()depends on against whatfingerprint()hashes, and comparing cached and fresh figures in the in-memory test database.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atapplications/views.py:864-876(Snooze),:705(ReminderCreateView),:739(ReminderCompleteView),:752(ReminderUpdateView),:788(ReminderLaterView),:776(ReminderDeleteView) and:977-987(InterviewUpdateView.form_valid);applications/analytics.pyand the other locations have not moved. Since #316 the same reminder actions are also offered on the calendar, through the same views.Related to #231 (closed), whose item 10 introduced this cache. This issue adds what that key does not cover.
insights_for()keeps the result ofbuild()for up to a day underfingerprint(), whose docstring says that none of its inputs moving means the figures "cannot have" moved. They can. The key covers applications, timeline entries, listings, companies, contacts, the language and the quiet threshold.build()also reads reminders, interviews, the current time and the active time zone, and none of those is in the key. So snoozing an application, correcting an interview's kind, an interview ending or a day passing leaves the cached figures wrong, while the Gone quiet and Coming up widgets beside them are computed per request and disagree.Where:
applications/analytics.py:303-359,fingerprint: counts and newest stamps ofApplication,ApplicationEvent,JobPosting,CompanyandContact, plusSHAPE, the language, the user andquiet_after_days. NoReminder, noInterview, no time, no zone.applications/analytics.py:362-381,insights_for, withINSIGHTS_TTLof 24 hours (:300).applications/analytics.py:384-553,build, reads outside the key::463interviews_ahead = diary.upcoming().count(), which comparesends_atwith the time (applications/models.py:678-680);:464-471interview_kinds, fromInterview.kind;:476-484and:496-497quiet_now,quiet_by_companyand everySourceRow.quiet, fromquiet_applications(), whose condition (applications/models.py:83-100, annotations at:248-264) reads the time,has_future_reminderandhas_unsettled_interview;:541-551by_month, whereTruncMonth("applied_at")buckets in the zonecore/middleware.py:127-129activated for the request;:441-454still_waitingcomparesapplied_atwith the time, which matters only for an application dated in the future.applications/views.py:824-836) andReminderCreateView(:671) create aReminder;ReminderCompleteView(:693),ReminderUpdateView(:706),ReminderLaterView(:748) andReminderDeleteView(:735) change or remove one; none touches the application or its timeline.InterviewUpdateView.form_valid(:937-947) saves a changed kind, andreschedule_interview(applications/services.py:441-442) returns before writing a timeline entry when the times are unchanged.core/widgets.py:368-380: the figures widgets readinsights_for;Sources.quiet(the Gone quiet widget) is computed per request.templates/widgets/sources.html:30-50,templates/widgets/_source_table.html:33,templates/widgets/industries.html:29andtemplates/widgets/interviews_summary.html:9-13.What happens (1 to 4 were checked on the snapshot):
build()says 0, but the cached figures still sayquiet_now == 1, the Quiet column of the sources and industries tables still counts it, and the line naming quiet companies still names it. Completing, moving or deleting a reminder goes stale the other way.announce_quiet_applicationsstampsupdated_aton the application it announces (applications/quiet.py:98), which moves the key within one pass (300 seconds indocker/compose.yml:63). It is not hidden when no scheduler runs (it is an opt-in Compose profile,docker/compose.yml:46), or when the application was already announced for this silence, for instance quiet again once a reminder falls due.Each lasts until an application, timeline entry, listing, company or contact changes, or 24 hours pass.
Why it matters: two widgets on one dashboard give different answers to "what has gone quiet", and the one the person just acted on appears not to have listened.
quiet_conditionpromises that the badge and the block "can never come to disagree" andapplications/quiet.py:11-12that the figures agree with them; with the cache they do not. The fingerprint's docstring states a guarantee that a later change will rely on.Fix: either
quiet_now,quiet_by_company, eachSourceRow.quiet,interviews_ahead,interview_kinds,still_waiting) out of the cached object and compute them per request fromSources.quietand one count query each, as Gone quiet already is; orMax("updated_at")of the person'sReminderandInterviewrows, and a time bucket that ends at the next moment a figure can change (the local date, the earliestends_atofupcoming()and the earliest due time of an open reminder).In both cases add the active zone's name to the key (
timezone.get_current_timezone_name()), sinceby_monthstays cached, and correct the docstring offingerprint.Done when
tests/test_insights.pyhas a test that warmsinsights_for, puts a future reminder on a quiet application (through Snooze and through the reminder form), and expectsinsights_for(user).quiet_nowand the source row'squietto equalquiet_applications(user).count(), which is 0. It fails today.applications:interview_updateand expectsinterview_kindsto follow.build().insights_forin one zone, activates another in which an application'sapplied_atfalls in a different month, and expectsby_monthto matchbuild().