Pantheon/tests/test_registry.c

276 lines
11 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/*
* test_registry.c — dispatch-layer registry unit tests (Phase G1).
*
* Exercises the in-memory instance table in-process (no sockets): register,
* heartbeat load updates, deduped /v1/models, model-level /registry/fleet,
* TTL eviction (via reg_sweep with an injected clock), and least-busy pick
* (max free_slots, min queue_depth, round-robin tiebreak + failover exclude).
*
* Links only registry.c + json_reader.c + json_writer.c — no engine/CUDA.
*/
#include "../src/serve/registry.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static int g_failed = 0;
#define CHECK(cond, msg) do { \
if (!(cond)) { \
fprintf(stderr, " FAIL: %s (%s:%d)\n", (msg), __FILE__, __LINE__); \
g_failed++; \
} \
} while (0)
/* substring presence helper for JSON assertions. */
static int contains(const char *hay, const char *needle)
{
return hay && strstr(hay, needle) != NULL;
}
/* count occurrences of a substring. */
static int count_occurrences(const char *hay, const char *needle)
{
int n = 0;
const char *p = hay;
size_t nl = strlen(needle);
while ((p = strstr(p, needle)) != NULL) { n++; p += nl; }
return n;
}
static void reg_one(reg_registry *r, const char *model, const char *inst,
const char *node, const char *endpoint, int cap)
{
char body[512];
snprintf(body, sizeof(body),
"{\"model_id\":\"%s\",\"instance_id\":\"%s\",\"node_id\":\"%s\","
"\"endpoint\":\"%s\",\"transport\":\"local\",\"capacity\":%d}",
model, inst, node, endpoint, cap);
reg_status st = reg_register_json(r, body);
CHECK(st == REG_OK, "register should succeed");
}
static void heartbeat(reg_registry *r, const char *inst, int active, int queue,
int freeS, int healthy)
{
char body[256];
snprintf(body, sizeof(body),
"{\"instance_id\":\"%s\",\"load\":{\"active_slots\":%d,"
"\"queue_depth\":%d,\"free_slots\":%d},\"healthy\":%s}",
inst, active, queue, freeS, healthy ? "true" : "false");
reg_status st = reg_heartbeat_json(r, body);
CHECK(st == REG_OK, "heartbeat should succeed");
}
int main(void)
{
printf("== registry unit tests ==\n");
reg_registry r;
CHECK(reg_init(&r) == 0, "reg_init");
/* --- Register the fleet: 4 model_ids, ornith has TWO instances (replicas). */
reg_one(&r, "saber-orch", "saber-1", "node0", "http://127.0.0.1:8201", 4);
reg_one(&r, "qwen36-base", "qwen36-1", "node0", "http://127.0.0.1:8202", 4);
reg_one(&r, "nex-n2-mini", "nex-1", "node0", "http://127.0.0.1:8203", 4);
reg_one(&r, "ornith", "ornith-A", "node0", "http://127.0.0.1:8204", 4);
reg_one(&r, "ornith", "ornith-B", "node0", "http://127.0.0.1:8205", 4);
/* --- /v1/models is deduped by model_id: 4 entries, ornith once, live=2. */
{
char *models = reg_models_json(&r);
CHECK(models != NULL, "models json");
CHECK(count_occurrences(models, "\"object\":\"model\"") == 4,
"4 deduped models");
CHECK(count_occurrences(models, "\"id\":\"ornith\"") == 1,
"ornith appears once");
CHECK(contains(models, "\"live\":2"), "ornith live count = 2");
free(models);
}
/* --- /registry/fleet: ornith model shows 2 instances + total_free_slots. */
heartbeat(&r, "ornith-A", 1, 0, 3, 1); /* 3 free */
heartbeat(&r, "ornith-B", 3, 0, 1, 1); /* 1 free */
{
char *fleet = reg_fleet_json(&r);
CHECK(fleet != NULL, "fleet json");
CHECK(contains(fleet, "\"model_id\":\"ornith\""), "fleet has ornith");
CHECK(contains(fleet, "\"instance_id\":\"ornith-A\"") &&
contains(fleet, "\"instance_id\":\"ornith-B\""),
"fleet lists both ornith instances");
CHECK(contains(fleet, "\"total_free_slots\":4"),
"ornith total_free_slots = 3+1 = 4");
free(fleet);
}
/* --- Least-busy: ornith-A (3 free) beats ornith-B (1 free). */
{
reg_instance pick;
int hits_A = 0, hits_B = 0;
for (int i = 0; i < 10; i++) {
CHECK(reg_pick_least_busy(&r, "ornith", NULL, 0, &pick),
"pick ornith");
if (strcmp(pick.instance_id, "ornith-A") == 0) hits_A++;
else if (strcmp(pick.instance_id, "ornith-B") == 0) hits_B++;
}
CHECK(hits_A == 10 && hits_B == 0, "least-busy always picks freer A");
}
/* --- Round-robin on ties: make both equal, expect alternation. */
heartbeat(&r, "ornith-A", 0, 0, 4, 1);
heartbeat(&r, "ornith-B", 0, 0, 4, 1);
{
reg_instance pick;
int hits_A = 0, hits_B = 0;
for (int i = 0; i < 10; i++) {
reg_pick_least_busy(&r, "ornith", NULL, 0, &pick);
if (strcmp(pick.instance_id, "ornith-A") == 0) hits_A++; else hits_B++;
}
CHECK(hits_A > 0 && hits_B > 0, "round-robin spreads across tied replicas");
CHECK(hits_A + hits_B == 10, "all picks accounted");
}
/* --- Failover exclude: excluding A yields B (or vice versa). */
{
reg_instance pick;
const char *ex[1] = { "ornith-A" };
CHECK(reg_pick_least_busy(&r, "ornith", ex, 1, &pick),
"pick with A excluded");
CHECK(strcmp(pick.instance_id, "ornith-B") == 0,
"excluding A gives B");
/* Exclude both → no candidate. */
const char *ex2[2] = { "ornith-A", "ornith-B" };
CHECK(!reg_pick_least_busy(&r, "ornith", ex2, 2, &pick),
"excluding both ornith gives no pick");
}
/* --- Unknown model → no pick. */
{
reg_instance pick;
CHECK(!reg_pick_least_busy(&r, "nonesuch", NULL, 0, &pick),
"unknown model → no pick");
}
/* --- Unhealthy instances are skipped. */
{
heartbeat(&r, "ornith-A", 0, 0, 4, 0); /* A now unhealthy */
reg_instance pick;
int all_B = 1;
for (int i = 0; i < 5; i++) {
reg_pick_least_busy(&r, "ornith", NULL, 0, &pick);
if (strcmp(pick.instance_id, "ornith-B") != 0) all_B = 0;
}
CHECK(all_B, "unhealthy A skipped, all picks go to B");
heartbeat(&r, "ornith-A", 0, 0, 4, 1); /* restore */
}
/* --- TTL eviction: sweep with a future clock evicts all; models empties. */
{
/* Set all last_seen to "now"; sweeping at now+TTL+1 should evict all. */
time_t now = time(NULL);
int ev = reg_sweep(&r, now); /* nothing stale yet */
CHECK(ev == 0, "no eviction when fresh");
int total = 5;
ev = reg_sweep(&r, now + RW_TTL_SECONDS + 1);
CHECK(ev == total, "sweep evicts all stale instances");
char *models = reg_models_json(&r);
CHECK(models != NULL, "models json after evict");
CHECK(count_occurrences(models, "\"object\":\"model\"") == 0,
"no models after TTL eviction");
free(models);
}
/* --- context_window: advertised, MIN across replicas, and suppressed when
* any live replica hasn't reported one. The router may dispatch to ANY
* replica, so promising more than the smallest window on offer hands the
* client a prefill some replica would then reject. */
{
reg_registry w;
CHECK(reg_init(&w) == 0, "reg_init (window)");
/* Two replicas of one model_id at DIFFERENT windows. */
reg_status st = reg_register_json(&w,
"{\"model_id\":\"twin\",\"instance_id\":\"t-A\",\"node_id\":\"b\","
"\"endpoint\":\"http://127.0.0.1:9001\",\"capacity\":4,"
"\"context_window\":131072}");
CHECK(st == REG_OK, "register with context_window");
st = reg_register_json(&w,
"{\"model_id\":\"twin\",\"instance_id\":\"t-B\",\"node_id\":\"b\","
"\"endpoint\":\"http://127.0.0.1:9002\",\"capacity\":4,"
"\"context_window\":32000}");
CHECK(st == REG_OK, "register second replica");
char *models = reg_models_json(&w);
CHECK(contains(models, "\"context_window\":32000"),
"models advertises the SMALLEST replica window");
CHECK(!contains(models, "\"context_window\":131072"),
"models never advertises the larger replica window");
free(models);
/* A heartbeat carrying a clamped window MOVES the advertised number —
* the ladder-clamp case reaching the client through the router. */
st = reg_heartbeat_json(&w,
"{\"instance_id\":\"t-B\",\"load\":{\"active_slots\":0,"
"\"queue_depth\":0,\"free_slots\":4,\"context_window\":16000},"
"\"healthy\":true}");
CHECK(st == REG_OK, "heartbeat with context_window");
models = reg_models_json(&w);
CHECK(contains(models, "\"context_window\":16000"),
"a heartbeat clamp moves the advertised window");
free(models);
/* A heartbeat WITHOUT the field must not blank the known value — an
* older serve build heartbeating cannot erase what it reported before. */
heartbeat(&w, "t-B", 0, 0, 4, 1);
models = reg_models_json(&w);
CHECK(contains(models, "\"context_window\":16000"),
"a heartbeat with no window keeps the last known one");
free(models);
/* A replica that has NEVER reported one suppresses the field entirely:
* "undetected" is a state the client handles gracefully, a window it can
* be denied is not. */
st = reg_register_json(&w,
"{\"model_id\":\"twin\",\"instance_id\":\"t-C\",\"node_id\":\"b\","
"\"endpoint\":\"http://127.0.0.1:9003\",\"capacity\":4}");
CHECK(st == REG_OK, "register replica with no window");
models = reg_models_json(&w);
CHECK(!contains(models, "\"context_window\""),
"one silent replica suppresses the model-level window");
free(models);
char *fleet = reg_fleet_json(&w);
CHECK(contains(fleet, "\"context_window\":16000") &&
contains(fleet, "\"context_window\":131072"),
"fleet reports each instance's own window");
free(fleet);
reg_destroy(&w);
}
/* --- Bad requests. */
{
CHECK(reg_register_json(&r, "{\"instance_id\":\"x\"}") == REG_BAD_REQUEST,
"register missing model_id/endpoint → 400");
CHECK(reg_heartbeat_json(&r, "{\"instance_id\":\"ghost\"}") == REG_NOT_FOUND,
"heartbeat unknown instance → not found");
CHECK(reg_register_json(&r, "not json") == REG_BAD_REQUEST,
"malformed JSON → 400");
}
reg_destroy(&r);
if (g_failed) {
fprintf(stderr, "\n%d CHECK(s) FAILED\n", g_failed);
return 1;
}
printf("ALL REGISTRY UNIT TESTS PASSED\n");
return 0;
}