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):
| Concern | Value |
|---|---|
| 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
| Feature | Direction | Destination | Code |
|---|---|---|---|
| Chat messages + sidebar updates | server → user | /user/queue/messages, /user/queue/conversations | ConversationServiceImpl |
| Typing indicator | client → server → broadcast | /app/conversation/{id}/typing → /topic/conversation/{id}/typing | TypingController |
| Notifications | server → user | /user/queue/notifications | NotificationServiceImpl |
| Live audit-log feed | server → broadcast | /topic/audit-logs | AuditServiceImpl |
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.