Skip to main content

Scheduled Jobs and the WebSocket Stack

The backend's background work is deliberately small: three periodic housekeeping mechanisms and one STOMP WebSocket broker. This doc catalogues all of them so nothing runs "mysteriously".

1. Scheduled jobs

Scheduling is enabled once, by @EnableScheduling on PayrollBackendApplication. There are exactly two @Scheduled methods in the codebase, plus one sweep that intentionally is not @Scheduled.

1.1 Annual PH-holiday generation (cron, Dec 1, 03:00 Manila)

config/HolidayGenerationScheduler.java:

@Scheduled(cron = "0 0 3 1 12 *", zone = "Asia/Manila")
public void seedUpcomingYear() { ... }

Every December 1 at 03:00 Asia/Manila it seeds the default Philippine holiday calendar for the current and upcoming year, so a server that runs across a year boundary without restarting never computes January payroll against an empty calendar (an unseeded regular holiday would silently pay as an ordinary day — see ../business-rules.md).

It delegates to the same idempotent PhHolidayService.generateDefaultsForYear used by the startup seeder and the manual "generate defaults" endpoint, with the deterministic defaults defined in util/PhHolidayDefaults.java. Existing (date, name) rows — including admin edits — are never touched. Failures log a warning; they never crash the scheduler thread.

Its startup companion, config/DefaultHolidaySeeder.java, is not scheduled — it's an ApplicationRunner (@Order(90), so it runs before DemoDataSeeder) that performs the same current+next-year seeding on every boot. Between the two, the calendar is populated whether the process restarts often (seeder) or never (scheduler).

1.2 ALTCHA replay-registry sweep (fixed delay, 5 min)

security/altcha/AltchaService.java keeps an in-memory registry of accepted proof-of-work signatures so a solved CAPTCHA payload can't be replayed. Entries expire with the challenge (altcha.expirySeconds, default 1800s), and:

@Scheduled(fixedDelay = SWEEP_DELAY_MS) // 5 * 60 * 1000
void sweepExpiredSignatures() { ... }

purges expired entries every 5 minutes. In-memory is sufficient because each client deployment runs a single backend instance (../deployment/vps-guide.md).

1.3 Login rate-limit bucket sweep (opportunistic, not @Scheduled)

security/ratelimit/LoginRateLimitFilter.java keeps a Bucket4j token bucket per client-IP+path. It cannot use @Scheduled — the filter is constructed inline in SecurityConfiguration rather than registered as a Spring bean — so sweepIdleEntries() runs inside the request path: the first throttled request after each 10-minute interval pays the cleanup cost and evicts buckets idle for 10+ minutes. This is safe because an idle bucket has fully refilled anyway, so dropping it never grants an attacker extra attempts.

That's the complete list — notably there is no leave-accrual scheduler (leave balances are credited from the type's max_credits, not accrued over time; see ../business-rules.md) and no scheduled report/email jobs.

2. The WebSocket stack (STOMP over SockJS)

Server configuration

config/WebSocketConfig.java (@EnableWebSocketMessageBroker):

ConcernValue
Handshake endpoint/ws (with SockJS fallback)
Broker destinations (server → client)/topic/** (broadcast), /queue/** (typically per-user under /user/queue/**)
Application prefix (client → server)/app/** — routed to @MessageMapping methods
User destination prefix/user

The broker is Spring's in-memory simple broker — no external RabbitMQ/Redis, consistent with the one-backend-per-client deployment model.

Authentication: WebSocketAuthChannelInterceptor

config/WebSocketAuthChannelInterceptor.java intercepts the inbound channel and authenticates the STOMP CONNECT frame: it requires an Authorization: Bearer <jwt> native STOMP header (not an HTTP header — the SockJS handshake can't carry one reliably), validates the token with JwtTokenManager, loads the user, and attaches the Authentication as the session user — which is what makes convertAndSendToUser(...) route to the right session. A missing/invalid token fails the CONNECT with a MessageDeliveryException. Frames other than CONNECT pass through (the session is already authenticated).

Who uses it

FeatureDirectionDestinationCode
Chat messages + sidebar updatesserver → user/user/queue/messages, /user/queue/conversationsConversationServiceImpl
Typing indicatorclient → server → broadcast/app/conversation/{id}/typing/topic/conversation/{id}/typingTypingController
Notificationsserver → user/user/queue/notificationsNotificationServiceImpl
Live audit-log feedserver → broadcast/topic/audit-logsAuditServiceImpl

On the frontend, the consumers are useMessagingWebSocket, useNotificationsWebSocket and useAuditLogWebSocket (@stomp/stompjs + sockjs-client).

How it's reached in Docker

The frontend nginx proxies /ws to the backend (frontend/nginx.conf, location /ws) with the Upgrade/Connection headers set, so the browser talks WebSocket to the same origin as the app (:5173 in dev) — no CORS, no separate port.

3. Testing a WebSocket connection locally

With the dev stack up (docker compose up -d --build):

Smoke test the endpoint + proxy — SockJS exposes an info probe:

curl -s http://localhost:5173/ws/info
# → {"entropy":...,"origins":["*:*"],"cookie_needed":...,"websocket":true}

If this 404s, the nginx /ws location or the backend is the problem — no point debugging STOMP yet.

Full STOMP round-trip — log in, then connect with the same libraries the app uses. Run from frontend/ (so the imports resolve), e.g. save as ws-test.mjs and node ws-test.mjs:

import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';

const login = await fetch('http://localhost:5173/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'hr_demo', password: '<the seeded demo password>' }),
});
const { accessToken } = await login.json();

const client = new Client({
webSocketFactory: () => new SockJS('http://localhost:5173/ws'),
connectHeaders: { Authorization: `Bearer ${accessToken}` },
onConnect: () => {
console.log('CONNECTED');
client.subscribe('/user/queue/notifications', (f) => console.log('notification:', f.body));
client.subscribe('/topic/audit-logs', (f) => console.log('audit:', f.body));
},
onStompError: (f) => console.error('STOMP error', f.headers, f.body),
});
client.activate();

(hr_demo is a seeded dev login; its password is in ../reference/demo-users.md, which is not published to the public documentation site. Note the token goes in the STOMP connect headers, not an HTTP header.) Then trigger traffic: perform any mutation in the app (audit feed), or open two browser windows on the same conversation and type (typing topic).

Zero-setup alternative: open the app in two browser sessions as two different users and use the chat — the typing indicator and instant message delivery visually confirm the whole path (nginx → /ws → CONNECT auth → broker → /user/queue/...).

A common failure mode: an expired access token (15-minute lifetime) at CONNECT time fails the handshake — the app reconnects with a fresh token via its silent-refresh flow, but a hand-written script must re-login.