Fix inbox loading stall
Deploy to S3 / deploy (push) Successful in 34s Details

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/67/head
Talha Ahmed 2026-09-03 19:53:23 +05:00
parent 2101fbac74
commit 8540577531
4 changed files with 52 additions and 17 deletions

View File

@ -13,7 +13,7 @@ from sqlalchemy import Column, DateTime, case, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import defer, selectinload
from sqlmodel import Field, Relationship, SQLModel, select, true
from job.candidate.models import Activity, Feedback, Interviews
@ -845,7 +845,7 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, light: bool=False
):
statement = cls._apply_filters(
@ -859,6 +859,19 @@ class Inbox_Messages(SQLModel, table=True):
if top is not None:
statement = statement.limit(top)
# List screens never render these. Loading them for every row is what
# makes GET /inbox/all-applications hang on a full mailbox: each value
# is TOASTed (Graph payload, extracted CV, HTML body). Do not read
# them after this — a deferred access lazy-loads per row.
if light:
statement = statement.options(
defer(cls.full_email_response),
defer(cls.resume_text),
defer(cls.message_body),
defer(cls.message_reply),
defer(cls.match_reasoning),
)
result = await session.execute(statement)
return result.scalars().all()

View File

@ -13,9 +13,19 @@ _RESUME_STATUS = {
}
def _sender_name(message: Inbox_Messages) -> str:
"""Graph's display name when the payload carries one, else the raw address."""
sender_name = message.message_from
def _sender_name(message: Inbox_Messages, *, light: bool = False) -> str:
"""Graph's display name when the payload carries one, else the raw address.
`light` must not touch `full_email_response` the list query defers that
column, and reading it here would lazy-load the whole Graph payload per row.
"""
raw = message.message_from or ""
if light:
# "Jane Doe <jane@x.com>" → Jane Doe; otherwise the address as stored.
if "<" in raw and raw.endswith(">"):
return raw.split("<", 1)[0].strip() or raw
return raw
sender_name = raw
full = message.full_email_response
if isinstance(full, dict):
from_block = full.get("from")
@ -81,7 +91,7 @@ _PROCESSING_LABEL = {
}
def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict:
def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: bool = False) -> dict:
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
`position` is the mail subject and `source` is the To address, which is where
@ -92,15 +102,18 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict
processed / rejected from the processing_state column so those writes are
visible; the default `unread` state still follows message_read so existing
rows keep Read/Unread until someone PATCHes a later state.
`light=True` is the list path: skip resume_text / match_reasoning /
Graph-payload name lookup so we never touch columns the list query defers.
"""
state = (message.processing_state or "").strip().lower()
if state in ("imported", "processed", "rejected"):
processing = _PROCESSING_LABEL[state]
else:
processing = "Read" if message.message_read else "Unread"
return {
payload = {
"id": str(message.id),
"name": _sender_name(message),
"name": _sender_name(message, light=light),
"email": message.message_from,
"position": message.message_subject,
"source": message.message_to,
@ -114,11 +127,9 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict
"file_path": message.file_path,
"linkedin_slug": message.linkedin_slug or None,
"linkedin_url": linkedin_url or None,
"resume_text": message.resume_text,
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
"match_summary": message.match_summary,
"match_reasoning": message.match_reasoning,
"match_status": message.match_status,
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
@ -133,6 +144,10 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict
"processing_state": message.processing_state,
"source_channel_id": message.source_channel_id,
}
if not light:
payload["resume_text"] = message.resume_text
payload["match_reasoning"] = message.match_reasoning
return payload
def serialize_triage(row: Inbox_Message_Triage) -> dict:

View File

@ -275,13 +275,13 @@ class Email:
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True)
elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True)
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages]
return [serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
async def get_application_by_id(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)

View File

@ -411,7 +411,8 @@ async function fetchApplications(params) {
filePath: row.file_path || '',
linkedinSlug: row.linkedin_slug || '',
linkedinUrl: row.linkedin_url || '',
resumeText: row.resume_text || '',
// resume_text is not on the list payload (serialize_application
// light=True). The detail query fetches it for the open row.
atsScore: asAtsScore(row.ats_score),
phone: row.phone,
experience: row.experience,
@ -693,7 +694,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
>
{n > 0
? `${n} selected`
: `${total}${unreadCount ? ` · ${unreadCount}` : ''}`}
: `${total}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
</span>
{/* Same switch as the Per-page "All" entry below, surfaced at the top of
the queue where the eye actually is. */}
@ -886,7 +887,13 @@ export default function Inbox() {
const activeQuery = isAllChannel
? {
isPending: applicationsQuery.isPending || formQuery.isPending,
// Pending only while NOTHING has arrived. The old OR kept the
// combined query "pending" until the slower source finished, which
// painted six skeletons on top of the email rows that were already
// on screen the list looked broken on every visit.
isPending:
(applicationsQuery.isPending || formQuery.isPending)
&& !(mergedRows && mergedRows.length),
// one healthy source still renders; error only when both are down
isError: applicationsQuery.isError && formQuery.isError,
isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,