python
# python
import time, math
from collections import deque
from scipy import stats # assume available for z-test; in real infra use robust libs
class CanaryController:
def __init__(self, baseline_name, candidate_name, config):
self.base, self.cand = baseline_name, candidate_name
self.split = 0.0 # fraction to candidate
self.config = config
# sliding windows store tuples: (timestamp, latency_ms, is_error, kpi_value)
self.base_win = deque()
self.cand_win = deque()
def route_decision(self):
# Return True to route to candidate probabilistically
return random.random() < self.split
def ingest(self, record, to_candidate):
win = self.cand_win if to_candidate else self.base_win
win.append(record)
cutoff = time.time() - self.config['window_seconds']
while win and win[0][0] < cutoff:
win.popleft()
def summarize(self, win):
n = len(win)
if n==0: return None
lat = [r[1] for r in win]
err = [1 if r[2] else 0 for r in win]
kpi = [r[3] for r in win]
return {'n':n, 'lat_mean':sum(lat)/n, 'err_rate':sum(err)/n, 'kpi_mean':sum(kpi)/n}
def z_test_proportion(self, p1, n1, p2, n2):
# two-proportion z-test (approx); returns z, p
p_pool = (p1*n1 + p2*n2)/(n1+n2)
se = math.sqrt(p_pool*(1-p_pool)*(1/n1 + 1/n2))
if se==0: return 0,1
z = (p1 - p2)/se
p = 2*(1 - stats.norm.cdf(abs(z)))
return z, p
def t_test_mean(self, x1_mean, n1, x1_var, x2_mean, n2, x2_var):
# Welch's t-test approx using stats.ttest_ind if real samples available.
se = math.sqrt(x1_var/n1 + x2_var/n2)
if se==0: return 0,1
t = (x1_mean - x2_mean)/se
# degrees of freedom approximation omitted for brevity
p = 2*(1 - stats.norm.cdf(abs(t)))
return t, p
def evaluate(self):
s_base = self.summarize(self.base_win)
s_cand = self.summarize(self.cand_win)
if not s_base or not s_cand:
return 'insufficient'
# Minimum samples safeguard
if s_base['n'] < self.config['min_n'] or s_cand['n'] < self.config['min_n']:
return 'insufficient'
# Compute statistics (for latency and error-rate and KPI)
# For simplicity use mean and approximate variances from windows
lat_base = [r[1] for r in self.base_win]; lat_cand = [r[1] for r in self.cand_win]
kpi_base = [r[3] for r in self.base_win]; kpi_cand = [r[3] for r in self.cand_win]
import numpy as np
lbm, lbn = np.mean(lat_base), len(lat_base); lbv = np.var(lat_base, ddof=1)
lcm, lcn = np.mean(lat_cand), len(lat_cand); lcv = np.var(lat_cand, ddof=1)
kbm, kbn = np.mean(kpi_base), len(kpi_base); kbv = np.var(kpi_base, ddof=1)
kcm, kcn = np.mean(kpi_cand), len(kpi_cand); kcv = np.var(kpi_cand, ddof=1)
# Error rates
err_base = s_base['err_rate']; err_cand = s_cand['err_rate']
# Statistical tests with multiple-metric gating
# 1) KPI should be non-inferior (one-sided) within delta
delta = self.config['kpi_delta']
# we test (kcm - kbm) >= -delta -> if true candidate non-inferior
diff = kcm - kbm
# approximate z-test on means
se_kpi = math.sqrt(kcv/kcn + kbv/kbn)
if se_kpi==0: return 'insufficient'
z_kpi = (diff + delta)/se_kpi # positive z supports non-inferiority
p_kpi = 1 - stats.norm.cdf(z_kpi)
# 2) Error rate should not be significantly higher
z_err, p_err = self.z_test_proportion(err_cand, s_cand['n'], err_base, s_base['n'])
# 3) Latency should not regress beyond threshold
se_lat = math.sqrt(lcv/lcn + lbv/lbn)
z_lat = (lbm - lcm - self.config['lat_delta'])/se_lat if se_lat>0 else 0
p_lat = 1 - stats.norm.cdf(z_lat)
# Combine decisions: require KPI non-inferior (p_kpi < alpha), err not worse, lat not worse
alpha = self.config['alpha']
if p_kpi < alpha and p_err > alpha and p_lat > alpha:
return 'promote'
if p_err < alpha and err_cand > err_base + self.config['err_abs_threshold']:
return 'rollback'
if p_lat < alpha and lcm > lbm + self.config['lat_abs_threshold']:
return 'rollback'
return 'hold'
def act(self, decision):
if decision == 'promote':
self.split = 1.0
elif decision == 'rollback':
self.split = 0.0
elif decision == 'hold':
# gradually increase if allowed
self.split = min(1.0, self.split + self.config.get('ramp_step', 0.1))
# apply hysteresis delay, cooldowns are handled outside