FCM Shadow Mode — Panduan Phase P1
Status: 🟡 Ready — prerequisite extraction Phase 0–4 confirmed DONE 2026-05-19. Shadow mode eksekusi belum dimulai (tidak ada referensi polqo / SHADOW_MODE di services/firebase_service/internal/).
Owner: Backend Lead
Konteks: Ini adalah Phase P1 dari polqo-migration-plan.md
Prasyarat: ✅ Extraction Phase 4 cleanup confirmed live (folder direname fcm_service → firebase_service, core_api SDK-free via HTTP-only client).
Dokumen terkait:
architecture.md— desain servicepolqo-migration-plan.md— konteks migrasi penuh
1. Apa itu Shadow Mode?
Shadow mode adalah teknik dual-send: setiap push notification dikirim ke dua destinasi secara paralel —
- Primary — Firebase Kesles (existing, production) — response-nya dikembalikan ke caller
- Shadow — Polqo FCM Service — dipanggil async, failure-nya diabaikan
End user tidak merasakan perbedaan. Push tetap datang dari Firebase Kesles. Polqo adalah "shadow" yang berjalan di balik layar untuk diobservasi.
Tujuan shadow mode:
- Membuktikan Polqo bisa deliver push ke device Kesles sebelum kita switch
- Membandingkan delivery rate, error rate, dan latency secara live
- Memverifikasi token backfill sudah lengkap
- Mendeteksi edge case (token invalid handling, payload format, rate limit)
2. Arsitektur Shadow Mode
merchant_core_api
│
▼ HTTP POST /internal/fcm/send
firebase-service (Kesles, port 8093)
│
├──── goroutine (async) ───────────────────────────────┐
│ POST api.polqo.com/api/fcm/send?tenant=kesles │
│ (shadow — fire and forget) │
│ error/success → shadow_log only │
│ ▼
│ polqo-fcm-service
│ │
│ Firebase (Polqo project)
│
▼ response (dari Firebase Kesles, bukan Polqo)
merchant_core_api
Kunci desain:
- Polqo call berjalan di goroutine terpisah (non-blocking)
- Timeout Polqo shadow call: 3 detik (tidak menghambat primary response)
- Jika Polqo gagal → log error ke shadow log, continue
- Caller tidak tahu apakah shadow berhasil atau tidak
3. Implementasi di firebase-service
3.1 Konfigurasi
# Shadow mode config
POLQO_SHADOW_ENABLED=true
POLQO_SHADOW_BASE_URL=https://api.polqo.com
POLQO_SHADOW_TENANT_ID=kesles
POLQO_SHADOW_API_KEY=<api-key-dari-polqo>
POLQO_SHADOW_TIMEOUT_MS=3000
POLQO_SHADOW_LOG_SUCCESS=true # log sukses juga (tidak hanya error)
3.2 Logika send handler (pseudo-code)
func (h *Handler) handleSend(w http.ResponseWriter, r *http.Request) {
// ... parse request ...
// Primary: kirim ke Firebase Kesles
result, err := h.firebaseClient.Send(ctx, payload)
// Shadow: kirim ke Polqo async (non-blocking)
if h.cfg.ShadowEnabled {
go h.shadowSend(payload, result)
}
// Kembalikan response dari primary (Firebase Kesles)
writeResponse(w, result, err)
}
func (h *Handler) shadowSend(payload Payload, primaryResult Result) {
ctx, cancel := context.WithTimeout(context.Background(), h.cfg.ShadowTimeout)
defer cancel()
shadowResult, err := h.polqoClient.Send(ctx, payload)
h.logShadowResult(ShadowLog{
PushToken: payload.PushToken,
EventType: payload.Type,
PrimaryStatus: primaryResult.Status,
ShadowStatus: shadowResultStatus(shadowResult, err),
ShadowError: errorString(err),
PrimaryInvalidated: primaryResult.Invalidated,
ShadowInvalidated: shadowResult.Invalidated,
Timestamp: time.Now(),
})
}
3.3 Shadow log schema
Log setiap shadow call sebagai structured JSON ke stdout:
{
"level": "info",
"event": "shadow_send",
"push_token_prefix": "cABC...",
"type": "transaction_info",
"primary_status": "sent",
"shadow_status": "sent",
"shadow_error": null,
"primary_invalidated": false,
"shadow_invalidated": false,
"shadow_duration_ms": 145,
"timestamp": "2026-05-04T10:00:00Z"
}
Error shadow:
{
"level": "warn",
"event": "shadow_send_error",
"push_token_prefix": "cDEF...",
"type": "login_alert",
"primary_status": "sent",
"shadow_status": "error",
"shadow_error": "context deadline exceeded",
"shadow_duration_ms": 3001,
"timestamp": "2026-05-04T10:00:01Z"
}
4. Polqo Client Interface
firebase-service butuh HTTP client sederhana untuk Polqo shadow call:
type PolqoFCMClient struct {
BaseURL string
TenantID string
APIKey string
http *http.Client
}
func (c *PolqoFCMClient) Send(ctx context.Context, payload SendPayload) (*ShadowResult, error) {
// Map internal payload ke format Polqo
body := PolqoSendRequest{
TenantID: c.TenantID,
PushToken: payload.PushToken,
Type: payload.Type,
Title: payload.Title,
Body: payload.Body,
Data: payload.Data,
}
req, _ := http.NewRequestWithContext(ctx, "POST",
c.BaseURL+"/api/fcm/send", jsonBody(body))
req.Header.Set("X-Tenant-ID", c.TenantID)
req.Header.Set("X-API-Key", c.APIKey)
resp, err := c.http.Do(req)
// ... parse response ...
}
Catatan: Format request ke Polqo perlu dikonfirmasi dengan tim Polqo sebelum implementasi. Sesuaikan jika Polqo menggunakan schema yang berbeda.
5. Metrik Paritas yang Diukur
Selama shadow mode, kita bandingkan primary vs shadow setiap 6 jam:
5.1 Delivery rate parity
shadow_delivery_rate = shadow_sent / total_shadow_attempts
primary_delivery_rate = primary_sent / total_primary_attempts
Target: shadow_delivery_rate >= primary_delivery_rate - 2%
5.2 Token coverage
tokens_missing_in_polqo = tokens dengan shadow_status="token_not_found" atau "unregistered"
Target: < 5% dari total push attempts
(token yang miss → perlu backfill ulang)
5.3 Error rate
shadow_error_rate = shadow_error / total_shadow_attempts
Target: < 5%
primary_error_rate = primary_error / total_primary_attempts
(baseline — shadow harus ≤ primary)
5.4 Latency (shadow only — tidak blocking primary)
shadow_p50 < 300ms
shadow_p99 < 1000ms
5.5 Token invalidation parity
shadow_invalidated_rate = shadow_invalidated / total_shadow_attempts
primary_invalidated_rate = primary_invalidated / total_primary_attempts
Target: shadow_invalidated_rate ≈ primary_invalidated_rate ± 1%
6. Analisis Shadow Log
6.1 Query agregat harian
Jalankan query berikut di log aggregator (atau manual dari stdout logs):
Summary paritas:
total_shadow_attempts: X
shadow_sent: Y (Y/X%)
shadow_error: Z (Z/X%)
shadow_token_not_found: W (W/X%)
primary_sent: A (A/X%)
primary_error: B (B/X%)
primary_invalidated: C (C/X%)
Token yang ada di primary tapi error di shadow:
shadow_status IN ['error', 'token_not_found'] AND primary_status = 'sent'
→ List push_token_prefix untuk investigasi
6.2 Success criteria untuk lanjut ke Phase P2
Semua harus terpenuhi selama 48 jam berturut-turut:
- Shadow delivery rate ≥ primary delivery rate − 2%
- Shadow error rate < 5%
- Token tidak ditemukan di Polqo < 5% dari total (setelah backfill)
- Tidak ada pola error sistemik (timeout konsisten, format payload rejection, dll)
- Shadow P99 latency < 1000ms (tidak menunjukkan bottleneck di Polqo)
Jika salah satu tidak terpenuhi → investigasi dulu, perbaiki, reset 48 jam counter.
7. Langkah Aktivasi Shadow Mode
7.1 Persiapan (sebelum enable)
- Koordinasi dengan Polqo: dapatkan
POLQO_SHADOW_API_KEYdanPOLQO_SHADOW_BASE_URL - Konfirmasi format endpoint dan payload Polqo
- Jalankan backfill token (Phase P0) terlebih dahulu
- Pastikan log aggregation setup (bisa baca shadow_log events)
7.2 Enable shadow mode
# Di firebase-service production env, tambahkan:
POLQO_SHADOW_ENABLED=true
POLQO_SHADOW_BASE_URL=https://api.polqo.com
POLQO_SHADOW_TENANT_ID=kesles
POLQO_SHADOW_API_KEY=xxx
POLQO_SHADOW_TIMEOUT_MS=3000
# Restart firebase-service
systemctl restart firebase-service # atau docker restart
7.3 Verifikasi shadow aktif
Segera setelah restart, cek log untuk event pertama:
# Lihat shadow log events
journalctl -u firebase-service -f | grep "shadow_send"
Seharusnya muncul dalam beberapa menit setelah ada transaksi atau event.
7.4 Monitoring harian selama shadow mode
Setiap hari selama shadow mode aktif, review:
- Shadow vs primary delivery count
- Error patterns di shadow log
- Token tidak ditemukan → tambahkan ke backfill jika > 5%
8. Disable Shadow Mode
Jika shadow mode menimbulkan masalah (resource/latency)
POLQO_SHADOW_ENABLED=false
# Restart firebase-service
Shadow mode tidak blocking — jadi ini jarang diperlukan. Namun jika goroutine leak atau memory issue terdeteksi, disable adalah langkah pertama.
Jika shadow mode selesai (lanjut ke Phase P2)
Shadow mode bisa tetap aktif selama Phase P2 (gradual cutover) sebagai cross-check tambahan. Nonaktifkan setelah Phase P3 (full cutover) selesai.
9. Edge Cases yang Perlu Diperhatikan
9.1 Bulk send shadow
Untuk /send-bulk request, shadow mode kirim satu goroutine per batch:
go h.shadowSendBulk(tokens, payload)
Log per-token di dalam bulk shadow send.
9.2 Token invalid di shadow tapi valid di primary
Artinya: Polqo punya stale token entry. Perlu trigger re-sync atau backfill untuk token tersebut.
Log pattern:
{ "primary_status": "sent", "shadow_status": "error", "shadow_error": "UNREGISTERED" }
Action: report ke Polqo untuk dibersihkan.
9.3 Token valid di shadow tapi invalid di primary
Artinya: primary sudah invalidate token (set is_active=false) tapi shadow masih mencoba kirim.
Ini expected — token management tetap di primary. Jika terjadi banyak → verifikasi bahwa invalidasi di primary tidak terlambat.
9.4 Shadow timeout konsisten
Jika shadow timeout (> 3s) lebih dari 20% dari shadow attempts → ada masalah infrastruktur Polqo. Escalate ke tim Polqo sebelum lanjut ke Phase P2.
10. Timeline Shadow Mode
| Hari | Aktivitas |
|---|---|
| H-1 | Backfill token (Phase P0), persiapan shadow mode |
| H0 | Enable shadow mode, verifikasi pertama |
| H0–H2 | Monitoring intensif — 3x/hari review shadow log |
| H2–H5 | Monitoring harian, perbaiki backfill jika ada token miss |
| H5+ | Decision point: lanjut ke Phase P2 jika semua metrik terpenuhi |
11. Dokumen Terkait
polqo-migration-plan.md— konteks penuh migrasiarchitecture.md— desain serviceextraction-plan.md— prasyarat (ekstraksi ke service)