Skip to main content

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:


1. Apa itu Shadow Mode?​

Shadow mode adalah teknik dual-send: setiap push notification dikirim ke dua destinasi secara paralel —

  1. Primary — Firebase Kesles (existing, production) — response-nya dikembalikan ke caller
  2. 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_KEY dan POLQO_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​

HariAktivitas
H-1Backfill token (Phase P0), persiapan shadow mode
H0Enable shadow mode, verifikasi pertama
H0–H2Monitoring intensif — 3x/hari review shadow log
H2–H5Monitoring harian, perbaiki backfill jika ada token miss
H5+Decision point: lanjut ke Phase P2 jika semua metrik terpenuhi

11. Dokumen Terkait​