Mitra ping: decouple stale-after from app cadence

Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:

- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
  operator-facing offline threshold. What you set is what you get —
  no multiplier. Default 45s (preserves today's effective grace at
  the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
  the mitra app sends a heartbeat. Backend-fixed per deployment;
  surfaced to the mitra app via /api/mitra/status.

Backend:
- config.service: getMitraPingConfig returns the new tuple
  {require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
  Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
  stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
  instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
  stale_after_seconds >= cadence, returns 422 with a clear message
  ("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
  mitra_ping_interval_seconds key is left in place (vestigial) —
  no live code reads it; safe to drop after one release.

Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
  as the Timer.periodic interval. Defaults to 30s if missing (older
  backend safety).

Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
  min={heartbeat_cadence_seconds}, shows the cadence as a read-only
  value with explanation that it's env-controlled.

Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
  stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 20:39:59 +08:00
parent 1653482d54
commit a8c20d929e
6 changed files with 80 additions and 22 deletions

View File

@@ -295,6 +295,18 @@ const migrate = async () => {
ON CONFLICT (key) DO NOTHING ON CONFLICT (key) DO NOTHING
` `
// Mitra reachability — replaces the implicit `ping_interval * 3` grace
// window with an operator-facing "max heartbeat age" knob. The companion
// heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default
// 30s). Default 45s keeps the same effective grace as the old 15s ping × 3.
// `mitra_ping_interval_seconds` is left in place (vestigial) — no live code
// path reads it anymore; safe to drop after one release.
await sql`
INSERT INTO app_config (key, value)
VALUES ('mitra_stale_after_seconds', '{"value": 45}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.2: Mitra Request Activity Log --- // --- Phase 3.2: Mitra Request Activity Log ---
await sql` await sql`

View File

@@ -9,7 +9,7 @@ import {
getFreeTrialConfig, setFreeTrialConfig, getFreeTrialConfig, setFreeTrialConfig,
getExtensionTimeoutConfig, setExtensionTimeoutConfig, getExtensionTimeoutConfig, setExtensionTimeoutConfig,
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig, getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
getSensitivityConfig, setSensitivityConfig, getSensitivityConfig, setSensitivityConfig,
getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes,
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
@@ -173,14 +173,23 @@ export const internalConfigRoutes = async (app) => {
app.patch('/mitra-ping', { app.patch('/mitra-ping', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => { }, async (request, reply) => {
const { require_ping, ping_interval_seconds } = request.body ?? {} const { require_ping, stale_after_seconds } = request.body ?? {}
if (require_ping !== undefined && typeof require_ping !== 'boolean') { if (require_ping !== undefined && typeof require_ping !== 'boolean') {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } }) return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'require_ping must be a boolean' } })
} }
if (ping_interval_seconds !== undefined && (typeof ping_interval_seconds !== 'number' || ping_interval_seconds < 5)) { if (stale_after_seconds !== undefined) {
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'ping_interval_seconds must be a number >= 5' } }) const cadence = getMitraHeartbeatCadenceSeconds()
if (typeof stale_after_seconds !== 'number' || stale_after_seconds < cadence) {
return reply.code(422).send({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: `stale_after_seconds must be a number >= heartbeat cadence (${cadence}s)`,
},
})
} }
const config = await setMitraPingConfig({ require_ping, ping_interval_seconds }) }
const config = await setMitraPingConfig({ require_ping, stale_after_seconds })
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })

View File

@@ -128,18 +128,38 @@ export const getEarlyEndConfig = async () => {
} }
} }
// --- Phase 3.1: Mitra Ping Config --- // --- Mitra reachability config ---
//
// Two separate concerns, deliberately decoupled:
// - heartbeat_cadence_seconds: how often the mitra app sends a heartbeat.
// Fixed per backend deployment via the MITRA_HEARTBEAT_CADENCE_SECONDS
// env (default 30). The mitra app reads this from /api/mitra/status and
// uses it directly as its Timer.periodic interval.
// - stale_after_seconds: how long the backend tolerates silence before
// marking a mitra offline. DB-stored, CC-tunable. Must be >= the
// heartbeat cadence (CC PATCH validates this).
//
// `require_ping` stays as the master switch — when false, the auto-offline
// sweep is skipped entirely and mitras stay online forever once they toggle.
export const getMitraHeartbeatCadenceSeconds = () => {
const raw = process.env.MITRA_HEARTBEAT_CADENCE_SECONDS
if (!raw || raw.trim() === '') return 30
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
export const getMitraPingConfig = async () => { export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
const [intervalRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_ping_interval_seconds'` const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
return { return {
require_ping: requireRow?.value?.value ?? true, require_ping: requireRow?.value?.value ?? true,
ping_interval_seconds: intervalRow?.value?.value ?? 15, stale_after_seconds: staleRow?.value?.value ?? 45,
heartbeat_cadence_seconds: getMitraHeartbeatCadenceSeconds(),
} }
} }
export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }) => { export const setMitraPingConfig = async ({ require_ping, stale_after_seconds }) => {
if (require_ping !== undefined) { if (require_ping !== undefined) {
await sql` await sql`
INSERT INTO app_config (key, value, updated_at) INSERT INTO app_config (key, value, updated_at)
@@ -147,10 +167,10 @@ export const setMitraPingConfig = async ({ require_ping, ping_interval_seconds }
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
` `
} }
if (ping_interval_seconds !== undefined) { if (stale_after_seconds !== undefined) {
await sql` await sql`
INSERT INTO app_config (key, value, updated_at) INSERT INTO app_config (key, value, updated_at)
VALUES ('mitra_ping_interval_seconds', ${sql.json({ value: ping_interval_seconds })}, NOW()) VALUES ('mitra_stale_after_seconds', ${sql.json({ value: stale_after_seconds })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
` `
} }

View File

@@ -96,7 +96,9 @@ export const getStatus = async (mitraId) => {
return { return {
...status, ...status,
require_ping: pingConfig.require_ping, require_ping: pingConfig.require_ping,
ping_interval_seconds: pingConfig.ping_interval_seconds, // The app reads this to set its Timer.periodic interval. Backend-fixed
// (via env), not operator-tunable.
heartbeat_cadence_seconds: pingConfig.heartbeat_cadence_seconds,
} }
} }
@@ -134,7 +136,12 @@ export const autoOfflineStaleMitras = async () => {
// If ping is not required, skip the auto-offline sweep entirely // If ping is not required, skip the auto-offline sweep entirely
if (!pingConfig.require_ping) return 0 if (!pingConfig.require_ping) return 0
const staleSeconds = pingConfig.ping_interval_seconds * 3 // stale_after_seconds is the operator-facing knob — what they set is what
// they get. No multiplier, no implicit "tolerate N missed heartbeats"
// contract baked in. The CC PATCH validates that the value is >= the env-
// driven heartbeat cadence so single missed pings can't flip a mitra
// offline.
const staleSeconds = pingConfig.stale_after_seconds
const stale = await sql` const stale = await sql`
UPDATE mitra_online_status UPDATE mitra_online_status
SET is_online = false, last_offline_at = NOW(), updated_at = NOW() SET is_online = false, last_offline_at = NOW(), updated_at = NOW()

View File

@@ -452,7 +452,7 @@ export default function SettingsPage() {
<section style={{ marginBottom: 24 }}> <section style={{ marginBottom: 24 }}>
<h2>Mitra Online Status (Ping)</h2> <h2>Mitra Online Status (Ping)</h2>
<p>Konfigurasi apakah mitra harus mengirim ping (heartbeat) untuk tetap online.</p> <p>Mitra dianggap online selama heartbeat terakhir berusia ambang batas. Cadence (frekuensi ping aplikasi) di-fix oleh server lewat env var.</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input <input
type="checkbox" type="checkbox"
@@ -465,21 +465,27 @@ export default function SettingsPage() {
<p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}> <p style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra. Jika dinonaktifkan, mitra akan tetap online tanpa perlu mengirim ping. QC bertanggung jawab atas kualitas layanan mitra.
</p> </p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<label>Interval Ping:</label> <label>Ambang offline (heartbeat terakhir lebih lama dari):</label>
<input <input
type="number" type="number"
min="5" min={mpData?.heartbeat_cadence_seconds ?? 30}
value={mpData?.ping_interval_seconds ?? 15} value={mpData?.stale_after_seconds ?? 45}
onChange={e => { onChange={e => {
const val = parseInt(e.target.value, 10) const val = parseInt(e.target.value, 10)
if (val >= 5) mpMutation.mutate({ ping_interval_seconds: val }) const floor = mpData?.heartbeat_cadence_seconds ?? 30
if (Number.isFinite(val) && val >= floor) {
mpMutation.mutate({ stale_after_seconds: val })
}
}} }}
disabled={mpMutation.isPending} disabled={mpMutation.isPending}
style={{ width: 80 }} style={{ width: 80 }}
/> />
<span>detik</span> <span>detik</span>
</div> </div>
<p style={{ fontSize: 12, color: '#666', marginBottom: 0 }}>
Cadence ping mitra: <strong>{mpData?.heartbeat_cadence_seconds ?? 30} detik</strong> (server-set via MITRA_HEARTBEAT_CADENCE_SECONDS env). Nilai ambang minimum mengikuti cadence tidak bisa lebih rendah.
</p>
{mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>} {mpMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
</section> </section>

View File

@@ -31,7 +31,11 @@ class StatusErrorData extends OnlineStatusData {
class OnlineStatus extends _$OnlineStatus { class OnlineStatus extends _$OnlineStatus {
Timer? _heartbeatTimer; Timer? _heartbeatTimer;
bool _requirePing = true; bool _requirePing = true;
int _pingIntervalSeconds = 15; // Heartbeat cadence is backend-fixed (MITRA_HEARTBEAT_CADENCE_SECONDS env,
// default 30s). Surfaced via /api/mitra/status. The CC-tunable
// mitra_stale_after_seconds is the offline threshold and lives entirely
// server-side — the app doesn't care, it just pings on a steady cadence.
int _heartbeatCadenceSeconds = 30;
@override @override
OnlineStatusData build() => const StatusInitialData(); OnlineStatusData build() => const StatusInitialData();
@@ -41,7 +45,7 @@ class OnlineStatus extends _$OnlineStatus {
final response = await ref.read(apiClientProvider).get('/api/mitra/status'); final response = await ref.read(apiClientProvider).get('/api/mitra/status');
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
_requirePing = data['require_ping'] as bool? ?? true; _requirePing = data['require_ping'] as bool? ?? true;
_pingIntervalSeconds = data['ping_interval_seconds'] as int? ?? 15; _heartbeatCadenceSeconds = data['heartbeat_cadence_seconds'] as int? ?? 30;
state = StatusLoadedData(isOnline: data['is_online'] as bool); state = StatusLoadedData(isOnline: data['is_online'] as bool);
} catch (e) { } catch (e) {
state = const StatusLoadedData(isOnline: false); state = const StatusLoadedData(isOnline: false);
@@ -83,7 +87,7 @@ class OnlineStatus extends _$OnlineStatus {
void _startHeartbeat() { void _startHeartbeat() {
_stopHeartbeat(); _stopHeartbeat();
_heartbeatTimer = Timer.periodic(Duration(seconds: _pingIntervalSeconds), (_) { _heartbeatTimer = Timer.periodic(Duration(seconds: _heartbeatCadenceSeconds), (_) {
_heartbeatTick(); _heartbeatTick();
}); });
} }