Status changes: applied_at and closed_at are decided from the caller's stale copy, not the locked row #543
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#543
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, readingchange_statusand replaying two moves made from copies fetched before either ran.Checked again on 2026-10-01 against
mainat0a5cad587: still present.Since #231,
change_statusre-reads the application withselect_for_update()so that the status it moves from is the one another request has just written. It copies onlystatusfrom the locked row. Whether to stampapplied_atand whether to set or clearclosed_atare still decided from the object the caller fetched before the lock, and both are then written withupdate_fields, so a concurrent move can leave the two timestamps contradicting the status.applications/services.py:149-151copieslocked.statusonto the caller's object;:162testsapplication.applied_at is Noneand:167-171testapplication.closed_aton the caller's object.closed_at. Copy B then moves it to Interviewing: B finds Rejected under the lock, but its ownclosed_atisNone, so it clears nothing. The result is an Interviewing application withclosed_atset.applied_at; copy B then moves it to Screening, seesapplied_at is Noneon its own copy and stamps it again with a later time, although the docstring says it is "never moved afterwards".closed_at) while B withdraws it from a copy that still hasclosed_at, and the withdrawn application is left with noclosed_at.closed_atdecides whether an application counts as open in lists, reports and exports, andapplied_atis what response times are measured from (#222). The event log stays right; the derived columns disagree with it.applied_atandclosed_atfromlockedonto the caller's object (asstatusalready is), or decide onlockedand mirror the result ontoapplication.Done when
tests/test_application_swaps.py: two copies of one application fetched before either move; afterchange_status(a, "rejected")andchange_status(b, "interviewing"), the row hasclosed_atNone; afterchange_status(a, "applied")andchange_status(b, "screening")from a draft,applied_atis the first stamp.Related to #231 (closed), whose point 11 introduced the lock and left these two columns reading the caller's copy.