Skip to content

Commit ffca8ca

Browse files
(SP: 3)[Shop][DB] Reduce Neon compute: throttle janitor + relax checkout polling + add sweep indexes (#375)
* (SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runbook (G0-G6) * (SP: 3) [Backend] add provider selector, fix payments gating, i18n checkout errors * Add shop category images to public * (SP: 3) [Shop][Monobank] I1 structured logging: codes + logging safety checks * (SP: 3) [Shop][Monobank] Fail-closed non-browser origin posture for webhook + janitor (ORIGIN_BLOCKED) * (SP: 3) [Shop][Monobank] [Shop][Monobank] J gate: add orders status ownership test and pass all pre-prod invariants * (SP: 3) [Shop][Monobank] review fixes (tests, logging, success UI) * (SP: 1) [Shop][Monobank] Tighten webhook log-code typing; harden DB tests; minor security/log/UI cleanups * (SP: 1) [Shop][Monobank] harden Monobank webhook (origin/PII-safe logs) and remove duplicate sha256 hashing * (SP: 1) [Cart] adding route for user orders to cart page * (SP: 1) [Cart] fix after review cart mpage and adding index for orders * (SP: 1) [Cart] Fix cart orders summary auth rendering and return totalCount for orders badge * (SP: 1) [Cart] remove console.warn from CartPageClient to satisfy monobank logging safety invariant, namespace localStorage cart by user and reset on auth change * (SP: 1) [Cart] rehydrate per cartOwnerId (remove didHydrate coupling) * (SP: 2)[Backend] shop/shipping schema migrations foundation * (SP: 2)[Backend] shop/shipping public routes + np cache + sync * (SP: 2)[Backend] shop/shipping: shipping persistence + currency policy * (SP: 2)[Backend] shop/shipping: webhook apply + psp fields + enqueue shipping * (SP: 2)[Backend] shop/shipping: shipments worker + internal run + np mock * (SP: 2)[Backend] shop/shipping: admin+ui shipping actions * (SP: 2)[Backend] shop/shipping: retention + log sanitizer + metrics * (SP: 1)[Backend] stabilize Monobank janitor (job1/job3) and fix failing apply-outcomes tests * (SP: 1) [db]: add shop shipping core migration * (SP: 1) [FIX] resolve merge artifacts in order details page * (SP: 1) [FIX] apply post-review fixes for shipping and admin flows * (SP: 1) [FIX] align cart shipping imports (localeToCountry + availability reason code) * (SP: 1) [FIX] hard-block checkout when shipping disabled + i18n reason mapping * (SP: 1) [FIX] harden webhook enqueue + shipping worker + NP catalog + cart fail-closed * (SP: 1) [FIX] Initialize shippingMethodsLoading to true to avoid premature checkout. * (SP: 1) [FIX] migration 17 * (SP: 1) [DB] migrarion to testind DB and adjusting tests * (SP: 1)[DB] slow down restock janitor + enforce prod interval floor * (SP: 1) [DB] add order status lite view (opt-in) + instrumentation * (SP: 1) [DB] replace checkout success router.refresh polling with backoff API polling * (SP: 1) [DB] throttle sessions activity heartbeat + use count(*) (PK invariant) * (SP: 1)[DB] enforce production min intervals for internal shipping jobs * (SP: 1) [DB] add minimal partial indexes for orders sweeps + rollout notes * (SP: 1) [DB] refactor sweep claim step to FOR UPDATE SKIP LOCKED batching * (SP: 1)[DB]: slow janitor schedule to every 30 minutes * (SP: 1)[DB] increase polling delays for MonobankRedirectStatus * (SP: 1)[FIX] harden webhooks + fix SSR hydration + janitor/np gates + sweeps refactor * (SP: 1)[FIX] harden shipping enqueue gating + apply NP interval floor
1 parent b94a599 commit ffca8ca

27 files changed

Lines changed: 5564 additions & 520 deletions

.github/workflows/shop-janitor-restock-stale.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Shop janitor - restock stale orders
22

33
on:
44
schedule:
5-
- cron: "*/5 * * * *"
5+
- cron: "*/30 * * * *"
66
workflow_dispatch: {}
77

88
concurrency:

frontend/app/[locale]/shop/checkout/success/MonobankRedirectStatus.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ const UI_STATE_TO_PAYMENT_STATUS_KEY = {
6969
const STATUS_TOKEN_KEY_PREFIX = 'shop:order-status-token:';
7070
const POLL_MAX_ATTEMPTS = 10;
7171
const POLL_MAX_DURATION_MS = 2 * 60 * 1000;
72-
const POLL_BASE_DELAY_MS = 1_500;
73-
const POLL_MAX_DELAY_MS = 12_000;
74-
const POLL_BUSY_RETRY_DELAY_MS = 250;
72+
const POLL_BASE_DELAY_MS = 3_000;
73+
const POLL_MAX_DELAY_MS = 15_000;
74+
const POLL_BUSY_RETRY_DELAY_MS = 1_000;
7575
const POLL_STOP_ERROR_CODES = new Set([
7676
'STATUS_TOKEN_REQUIRED',
7777
'STATUS_TOKEN_INVALID',
@@ -132,6 +132,27 @@ function normalizeToken(value: string | null | undefined): string | null {
132132
function parseOrderStatusPayload(payload: unknown): OrderStatusModel | null {
133133
if (!payload || typeof payload !== 'object') return null;
134134
const root = payload as Record<string, unknown>;
135+
136+
if (
137+
typeof root.id === 'string' &&
138+
root.id.trim() &&
139+
root.currency === 'UAH' &&
140+
typeof root.totalAmountMinor === 'number' &&
141+
Number.isFinite(root.totalAmountMinor) &&
142+
typeof root.paymentStatus === 'string' &&
143+
root.paymentStatus.trim() &&
144+
typeof root.itemsCount === 'number' &&
145+
Number.isFinite(root.itemsCount)
146+
) {
147+
return {
148+
id: root.id,
149+
currency: root.currency,
150+
totalAmountMinor: root.totalAmountMinor,
151+
paymentStatus: root.paymentStatus,
152+
itemsCount: root.itemsCount,
153+
};
154+
}
155+
135156
if (root.success !== true) return null;
136157

137158
const orderRaw = root.order;
@@ -181,6 +202,7 @@ async function fetchOrderStatus(args: {
181202
}): Promise<StatusResult> {
182203
try {
183204
const qp = new URLSearchParams();
205+
qp.set('view', 'lite');
184206
if (args.statusToken) {
185207
qp.set('statusToken', args.statusToken);
186208
}

frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,184 @@ import { useEffect, useRef } from 'react';
55

66
type Props = {
77
paymentStatus: string;
8-
maxMs?: number;
9-
intervalMs?: number;
108
};
119

12-
function isTerminal(status: string) {
13-
return status === 'paid' || status === 'failed' || status === 'refunded';
10+
const MAX_ATTEMPTS = 8;
11+
const MAX_DURATION_MS = 2 * 60 * 1000;
12+
const BASE_DELAY_MS = 2_000;
13+
const MAX_DELAY_MS = 15_000;
14+
const JITTER_RATIO = 0.2;
15+
const TERMINAL_STATUSES = new Set([
16+
'paid',
17+
'failed',
18+
'refunded',
19+
'needs_review',
20+
]);
21+
22+
type StatusFetchResult =
23+
| { ok: true; paymentStatus: string }
24+
| { ok: false; status: number; code: string };
25+
26+
function normalizeQueryValue(value: string | null): string | null {
27+
if (typeof value !== 'string') return null;
28+
const trimmed = value.trim();
29+
return trimmed.length ? trimmed : null;
30+
}
31+
32+
function isTerminal(status: string): boolean {
33+
return TERMINAL_STATUSES.has(status);
34+
}
35+
36+
function shouldStopOnError(status: number, code: string): boolean {
37+
if (status === 401 || status === 403) return true;
38+
if (status !== 400) return false;
39+
const normalized = code.trim().toUpperCase();
40+
return (
41+
normalized === 'STATUS_TOKEN_INVALID' ||
42+
normalized === 'INVALID_STATUS_TOKEN' ||
43+
normalized.endsWith('TOKEN_INVALID')
44+
);
45+
}
46+
47+
function getBackoffDelayMs(attempt: number): number {
48+
return Math.min(BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0), MAX_DELAY_MS);
49+
}
50+
51+
function withJitter(delayMs: number): number {
52+
const jitterMultiplier = 1 + (Math.random() * 2 - 1) * JITTER_RATIO;
53+
return Math.max(0, Math.floor(delayMs * jitterMultiplier));
54+
}
55+
56+
function getErrorCode(payload: unknown): string {
57+
if (!payload || typeof payload !== 'object') return 'INTERNAL_ERROR';
58+
const code = (payload as Record<string, unknown>).code;
59+
if (typeof code !== 'string') return 'INTERNAL_ERROR';
60+
const trimmed = code.trim();
61+
return trimmed.length ? trimmed : 'INTERNAL_ERROR';
62+
}
63+
64+
function parseLitePaymentStatus(payload: unknown): string | null {
65+
if (!payload || typeof payload !== 'object') return null;
66+
const root = payload as Record<string, unknown>;
67+
const paymentStatus = root.paymentStatus;
68+
if (typeof paymentStatus !== 'string') return null;
69+
const trimmed = paymentStatus.trim();
70+
return trimmed.length ? trimmed : null;
71+
}
72+
73+
async function fetchLiteOrderStatus(args: {
74+
orderId: string;
75+
tokenKey: string | null;
76+
tokenValue: string | null;
77+
signal: AbortSignal;
78+
}): Promise<StatusFetchResult> {
79+
const qp = new URLSearchParams();
80+
qp.set('view', 'lite');
81+
if (args.tokenKey && args.tokenValue) qp.set(args.tokenKey, args.tokenValue);
82+
83+
const endpoint = `/api/shop/orders/${encodeURIComponent(args.orderId)}/status?${qp.toString()}`;
84+
85+
const res = await fetch(endpoint, {
86+
method: 'GET',
87+
cache: 'no-store',
88+
headers: { 'Cache-Control': 'no-store' },
89+
credentials: 'same-origin',
90+
signal: args.signal,
91+
});
92+
93+
const body = await res.json().catch(() => ({}));
94+
if (!res.ok) {
95+
return { ok: false, status: res.status, code: getErrorCode(body) };
96+
}
97+
98+
const paymentStatus = parseLitePaymentStatus(body);
99+
if (!paymentStatus) {
100+
return { ok: false, status: 500, code: 'INVALID_STATUS_RESPONSE' };
101+
}
102+
103+
return { ok: true, paymentStatus };
14104
}
15105

16-
export default function OrderStatusAutoRefresh({
17-
paymentStatus,
18-
maxMs = 30_000,
19-
intervalMs = 1_500,
20-
}: Props) {
106+
export default function OrderStatusAutoRefresh({ paymentStatus }: Props) {
21107
const router = useRouter();
22-
const startedAtRef = useRef<number | null>(null);
108+
const didTerminalRefreshRef = useRef(false);
23109

24110
useEffect(() => {
25111
if (isTerminal(paymentStatus)) return;
26112

27-
if (startedAtRef.current == null) startedAtRef.current = Date.now();
113+
let cancelled = false;
114+
let timeoutId: number | null = null;
115+
let activeController: AbortController | null = null;
116+
const startedAtMs = Date.now();
117+
let attempts = 0;
118+
119+
const params = new URLSearchParams(window.location.search);
120+
const orderId = normalizeQueryValue(params.get('orderId'));
121+
if (!orderId) return;
122+
123+
const tokenKey = params.has('statusToken') ? 'statusToken' : null;
124+
const tokenValue =
125+
tokenKey === null ? null : normalizeQueryValue(params.get(tokenKey));
28126

29-
const id = window.setInterval(() => {
30-
const startedAt = startedAtRef.current ?? Date.now();
31-
if (Date.now() - startedAt > maxMs) {
32-
window.clearInterval(id);
33-
return;
127+
const wait = async (delayMs: number) =>
128+
new Promise<void>(resolve => {
129+
timeoutId = window.setTimeout(resolve, delayMs);
130+
});
131+
132+
const run = async () => {
133+
while (!cancelled) {
134+
if (attempts >= MAX_ATTEMPTS) return;
135+
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;
136+
137+
attempts += 1;
138+
const controller = new AbortController();
139+
activeController = controller;
140+
const result = await fetchLiteOrderStatus({
141+
orderId,
142+
tokenKey,
143+
tokenValue,
144+
signal: controller.signal,
145+
}).catch(
146+
(): StatusFetchResult => ({
147+
ok: false,
148+
status: 500,
149+
code: 'INTERNAL_ERROR',
150+
})
151+
);
152+
153+
if (cancelled) {
154+
return;
155+
}
156+
activeController = null;
157+
158+
if (result.ok) {
159+
if (isTerminal(result.paymentStatus)) {
160+
if (!didTerminalRefreshRef.current) {
161+
didTerminalRefreshRef.current = true;
162+
router.refresh();
163+
}
164+
return;
165+
}
166+
} else if (shouldStopOnError(result.status, result.code)) {
167+
return;
168+
}
169+
170+
if (attempts >= MAX_ATTEMPTS) return;
171+
if (Date.now() - startedAtMs >= MAX_DURATION_MS) return;
172+
173+
const delayMs = withJitter(getBackoffDelayMs(attempts));
174+
await wait(delayMs);
34175
}
35-
router.refresh();
36-
}, intervalMs);
176+
};
177+
178+
void run();
37179

38-
return () => window.clearInterval(id);
39-
}, [paymentStatus, router, maxMs, intervalMs]);
180+
return () => {
181+
cancelled = true;
182+
activeController?.abort();
183+
if (timeoutId !== null) window.clearTimeout(timeoutId);
184+
};
185+
}, [paymentStatus, router]);
40186

41187
return <span className="sr-only" aria-live="polite" />;
42188
}

frontend/app/api/sessions/activity/route.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ import { activeSessions } from '@/db/schema/sessions';
88

99
const SESSION_TIMEOUT_MINUTES = 15;
1010

11+
function getHeartbeatThrottleMs(): number {
12+
const raw = process.env.HEARTBEAT_THROTTLE_MS;
13+
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
14+
const fallback = 60_000;
15+
const floor = 1_000;
16+
if (!Number.isFinite(parsed)) return fallback;
17+
return Math.max(floor, parsed);
18+
}
19+
1120
export async function POST() {
1221
try {
1322
const cookieStore = await cookies();
@@ -17,15 +26,21 @@ export async function POST() {
1726
sessionId = randomUUID();
1827
}
1928

29+
const now = new Date();
30+
const heartbeatThreshold = new Date(
31+
now.getTime() - getHeartbeatThrottleMs()
32+
);
33+
2034
await db
2135
.insert(activeSessions)
2236
.values({
2337
sessionId,
24-
lastActivity: new Date(),
38+
lastActivity: now,
2539
})
2640
.onConflictDoUpdate({
2741
target: activeSessions.sessionId,
28-
set: { lastActivity: new Date() },
42+
set: { lastActivity: now },
43+
setWhere: lt(activeSessions.lastActivity, heartbeatThreshold),
2944
});
3045

3146
if (Math.random() < 0.05) {
@@ -44,7 +59,7 @@ export async function POST() {
4459

4560
const result = await db
4661
.select({
47-
total: sql<number>`count(distinct session_id)`,
62+
total: sql<number>`count(*)`,
4863
})
4964
.from(activeSessions)
5065
.where(gte(activeSessions.lastActivity, countThreshold));

0 commit comments

Comments
 (0)