Fix inbox loading stall
CI / checks (push) Successful in 2m31s
Details
CI / checks (push) Successful in 2m31s
Details
Co-authored-by: Cursor <cursoragent@cursor.com>pull/73/head^2
parent
0113509fa1
commit
22b2a7323f
|
|
@ -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
|
||||
|
|
@ -821,7 +821,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(
|
||||
|
|
@ -835,6 +835,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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -444,11 +444,8 @@ async function fetchApplications(params) {
|
|||
filePath: row.file_path || '',
|
||||
linkedinSlug: row.linkedin_slug || '',
|
||||
linkedinUrl: row.linkedin_url || '',
|
||||
// resume_text is deliberately DROPPED here. serialize_application ships
|
||||
// the whole extracted CV on every row, and the queue renders none of
|
||||
// it — but the cache would then hold a thousand full resumes for the
|
||||
// hour that LIST_CACHE keeps a page alive. The detail query fetches it
|
||||
// for the one row actually open, which is the only place it is read.
|
||||
// resume_text is not on the list payload (serialize_application
|
||||
// light=True). The detail query fetches it for the open row.
|
||||
atsScore: row.ats_score,
|
||||
phone: row.phone,
|
||||
experience: row.experience,
|
||||
|
|
@ -730,7 +727,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. */}
|
||||
|
|
@ -977,7 +974,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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue