mt: spawn the read-position bump off the thread GET
The thread view awaited update_read_position inline, so every tracked viewer of
an unauthenticated, unthrottled GET blocked the page render on a write to the
request pool (fuzz-2026-07-06 write-on-GET). Spawn it best-effort — the page
renders immediately and a dropped bump just re-bumps on the next view. The
read-position test polls for the async write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 files changed,
+30 insertions,
-16 deletions
| 198 |
198 |
|
})
|
| 199 |
199 |
|
.collect();
|
| 200 |
200 |
|
|
| 201 |
|
- |
// If tracked, update read position to the last post on the current page
|
|
201 |
+ |
// If tracked, update read position to the last post on the current page.
|
|
202 |
+ |
// Spawned off the response path: this is a DB write on an unauthenticated,
|
|
203 |
+ |
// unthrottled GET, so awaiting it inline made every tracked viewer block the
|
|
204 |
+ |
// page render on a write to the request pool (fuzz-2026-07-06 write-on-GET).
|
|
205 |
+ |
// Best-effort — a dropped bump just re-bumps on the next view.
|
| 202 |
206 |
|
if is_tracked
|
| 203 |
207 |
|
&& let Some(ref user) = session_user
|
| 204 |
208 |
|
&& let Some(last_post) = posts.last()
|
| 205 |
209 |
|
&& let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
|
| 206 |
210 |
|
{
|
| 207 |
|
- |
let _ = mt_db::mutations::update_read_position(
|
| 208 |
|
- |
&state.db, user.user_id, thread_uuid, last_post_id,
|
| 209 |
|
- |
).await;
|
|
211 |
+ |
let db = state.db.clone();
|
|
212 |
+ |
let user_id = user.user_id;
|
|
213 |
+ |
tokio::spawn(async move {
|
|
214 |
+ |
let _ = mt_db::mutations::update_read_position(&db, user_id, thread_uuid, last_post_id).await;
|
|
215 |
+ |
});
|
| 210 |
216 |
|
}
|
| 211 |
217 |
|
|
| 212 |
218 |
|
let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
|
| 123 |
123 |
|
// Track the thread
|
| 124 |
124 |
|
mt_db::mutations::track_thread(&h.db, user_id, thread_id).await.unwrap();
|
| 125 |
125 |
|
|
| 126 |
|
- |
// View thread — should update read position
|
|
126 |
+ |
// View thread — should update read position. The bump is now spawned off the
|
|
127 |
+ |
// response path, so poll briefly for it rather than reading immediately.
|
| 127 |
128 |
|
let thread_url = format!("/p/test/general/{}", thread_id);
|
| 128 |
129 |
|
h.client.get(&thread_url).await;
|
| 129 |
130 |
|
|
| 130 |
|
- |
// Check last_read_post_id is set
|
| 131 |
|
- |
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(
|
| 132 |
|
- |
"SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
|
| 133 |
|
- |
)
|
| 134 |
|
- |
.bind(user_id)
|
| 135 |
|
- |
.bind(thread_id)
|
| 136 |
|
- |
.fetch_optional(&h.db)
|
| 137 |
|
- |
.await
|
| 138 |
|
- |
.unwrap();
|
| 139 |
|
- |
assert!(row.is_some(), "Tracking row should exist");
|
| 140 |
|
- |
assert!(row.unwrap().0.is_some(), "last_read_post_id should be set after viewing");
|
|
131 |
+ |
let mut last_read: Option<uuid::Uuid> = None;
|
|
132 |
+ |
for _ in 0..50 {
|
|
133 |
+ |
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(
|
|
134 |
+ |
"SELECT last_read_post_id FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
|
|
135 |
+ |
)
|
|
136 |
+ |
.bind(user_id)
|
|
137 |
+ |
.bind(thread_id)
|
|
138 |
+ |
.fetch_optional(&h.db)
|
|
139 |
+ |
.await
|
|
140 |
+ |
.unwrap();
|
|
141 |
+ |
assert!(row.is_some(), "Tracking row should exist");
|
|
142 |
+ |
last_read = row.unwrap().0;
|
|
143 |
+ |
if last_read.is_some() {
|
|
144 |
+ |
break;
|
|
145 |
+ |
}
|
|
146 |
+ |
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
147 |
+ |
}
|
|
148 |
+ |
assert!(last_read.is_some(), "last_read_post_id should be set after viewing");
|
| 141 |
149 |
|
}
|
| 142 |
150 |
|
|
| 143 |
151 |
|
#[tokio::test]
|