248 lines
9.2 KiB
C
248 lines
9.2 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/*
|
|
* test_session_affinity.c — dispatch-layer session-affinity unit tests (PC0.2).
|
|
*
|
|
* Exercises the session -> instance map in-process (no sockets): record/lookup,
|
|
* refresh-on-hit, LRU eviction when full, forget-on-deregister/evict, plus the
|
|
* registry helpers affinity depends on (reg_find_live model+liveness gating,
|
|
* reg_sweep_collect / reg_deregister_json_collect reporting evicted ids).
|
|
*
|
|
* Links only session_affinity.c + registry.c + json_reader.c + json_writer.c —
|
|
* no engine/CUDA. Host-side, deterministic.
|
|
*/
|
|
|
|
#include "../src/serve/session_affinity.h"
|
|
#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)
|
|
|
|
static void reg_one(reg_registry *r, const char *model, const char *inst,
|
|
const char *endpoint, int cap)
|
|
{
|
|
char body[512];
|
|
snprintf(body, sizeof(body),
|
|
"{\"model_id\":\"%s\",\"instance_id\":\"%s\",\"node_id\":\"b\","
|
|
"\"endpoint\":\"%s\",\"transport\":\"local\",\"capacity\":%d}",
|
|
model, inst, endpoint, cap);
|
|
reg_status st = reg_register_json(r, body);
|
|
CHECK(st == REG_OK, "register should succeed");
|
|
}
|
|
|
|
/* --- session_affinity map: record / lookup / refresh / LRU / forget --------- */
|
|
|
|
static void test_map_basic(void)
|
|
{
|
|
printf("map: record + lookup + miss\n");
|
|
session_affinity sa;
|
|
CHECK(sa_init(&sa) == 0, "sa_init");
|
|
|
|
char out[RW_INSTANCE_ID_MAX];
|
|
CHECK(!sa_lookup(&sa, "sess-1", out, sizeof(out)), "unknown session → miss");
|
|
CHECK(!sa_lookup(&sa, NULL, out, sizeof(out)), "NULL session → miss");
|
|
CHECK(!sa_lookup(&sa, "", out, sizeof(out)), "empty session → miss");
|
|
|
|
sa_record(&sa, "sess-1", "ornith-A");
|
|
CHECK(sa_lookup(&sa, "sess-1", out, sizeof(out)), "recorded session → hit");
|
|
CHECK(strcmp(out, "ornith-A") == 0, "hit returns the bound instance");
|
|
|
|
/* Re-record overwrites the binding for the same session. */
|
|
sa_record(&sa, "sess-1", "ornith-B");
|
|
CHECK(sa_lookup(&sa, "sess-1", out, sizeof(out)), "still a hit after rebind");
|
|
CHECK(strcmp(out, "ornith-B") == 0, "rebind overwrites the instance");
|
|
|
|
/* No-ops: empty ids must not create bindings. */
|
|
sa_record(&sa, "", "ornith-A");
|
|
sa_record(&sa, "sess-x", "");
|
|
CHECK(!sa_lookup(&sa, "sess-x", out, sizeof(out)),
|
|
"record with empty instance is a no-op");
|
|
|
|
sa_destroy(&sa);
|
|
}
|
|
|
|
static void test_map_lru_eviction(void)
|
|
{
|
|
printf("map: LRU eviction when full\n");
|
|
session_affinity sa;
|
|
CHECK(sa_init(&sa) == 0, "sa_init");
|
|
|
|
char sid[32], out[RW_INSTANCE_ID_MAX];
|
|
/* Fill the table exactly. */
|
|
for (int i = 0; i < SA_MAX_SESSIONS; i++) {
|
|
snprintf(sid, sizeof(sid), "s%d", i);
|
|
sa_record(&sa, sid, "inst-A");
|
|
}
|
|
/* Touch s1 so it is NOT the LRU, then insert one more → s0 (LRU) is evicted. */
|
|
CHECK(sa_lookup(&sa, "s1", out, sizeof(out)), "s1 present before overflow");
|
|
sa_record(&sa, "overflow", "inst-B");
|
|
|
|
CHECK(!sa_lookup(&sa, "s0", out, sizeof(out)), "LRU (s0) evicted on overflow");
|
|
CHECK(sa_lookup(&sa, "s1", out, sizeof(out)), "recently-used s1 survives");
|
|
CHECK(sa_lookup(&sa, "overflow", out, sizeof(out)), "new session admitted");
|
|
|
|
sa_destroy(&sa);
|
|
}
|
|
|
|
static void test_map_forget_instance(void)
|
|
{
|
|
printf("map: forget every binding for a dead instance\n");
|
|
session_affinity sa;
|
|
CHECK(sa_init(&sa) == 0, "sa_init");
|
|
|
|
char out[RW_INSTANCE_ID_MAX];
|
|
sa_record(&sa, "s-a1", "inst-A");
|
|
sa_record(&sa, "s-a2", "inst-A");
|
|
sa_record(&sa, "s-b1", "inst-B");
|
|
|
|
int removed = sa_forget_instance(&sa, "inst-A");
|
|
CHECK(removed == 2, "forget drops both bindings on inst-A");
|
|
CHECK(!sa_lookup(&sa, "s-a1", out, sizeof(out)), "s-a1 gone");
|
|
CHECK(!sa_lookup(&sa, "s-a2", out, sizeof(out)), "s-a2 gone");
|
|
CHECK(sa_lookup(&sa, "s-b1", out, sizeof(out)), "inst-B binding untouched");
|
|
|
|
CHECK(sa_forget_instance(&sa, "nonesuch") == 0, "forget unknown → 0");
|
|
CHECK(sa_forget_instance(&sa, NULL) == 0, "forget NULL → 0");
|
|
|
|
sa_destroy(&sa);
|
|
}
|
|
|
|
/* --- registry helpers affinity relies on ----------------------------------- */
|
|
|
|
static void hb(reg_registry *r, const char *inst, int freeS, int healthy)
|
|
{
|
|
char body[256];
|
|
snprintf(body, sizeof(body),
|
|
"{\"instance_id\":\"%s\",\"load\":{\"active_slots\":0,"
|
|
"\"queue_depth\":0,\"free_slots\":%d},\"healthy\":%s}",
|
|
inst, freeS, healthy ? "true" : "false");
|
|
reg_heartbeat_json(r, body);
|
|
}
|
|
|
|
static void test_reg_find_live(void)
|
|
{
|
|
printf("registry: reg_find_live model + liveness gating\n");
|
|
reg_registry r;
|
|
CHECK(reg_init(&r) == 0, "reg_init");
|
|
reg_one(&r, "ornith", "ornith-A", "http://127.0.0.1:8201", 4);
|
|
reg_one(&r, "ornith", "ornith-B", "http://127.0.0.1:8202", 4);
|
|
|
|
reg_instance out;
|
|
CHECK(reg_find_live(&r, "ornith", "ornith-A", &out), "A is live for ornith");
|
|
CHECK(strcmp(out.endpoint, "http://127.0.0.1:8201") == 0, "returns A's endpoint");
|
|
CHECK(!reg_find_live(&r, "ornith", "ghost", &out), "unknown instance → false");
|
|
CHECK(!reg_find_live(&r, "other", "ornith-A", &out),
|
|
"right instance wrong model → false");
|
|
|
|
hb(&r, "ornith-A", 4, 0); /* A unhealthy */
|
|
CHECK(!reg_find_live(&r, "ornith", "ornith-A", &out),
|
|
"unhealthy instance → false (falls back to least-busy)");
|
|
|
|
reg_destroy(&r);
|
|
}
|
|
|
|
static void test_reg_collect_variants(void)
|
|
{
|
|
printf("registry: deregister/sweep report evicted ids\n");
|
|
reg_registry r;
|
|
CHECK(reg_init(&r) == 0, "reg_init");
|
|
reg_one(&r, "ornith", "ornith-A", "http://127.0.0.1:8201", 4);
|
|
reg_one(&r, "ornith", "ornith-B", "http://127.0.0.1:8202", 4);
|
|
|
|
char removed[RW_INSTANCE_ID_MAX];
|
|
reg_status st = reg_deregister_json_collect(
|
|
&r, "{\"instance_id\":\"ornith-A\"}", removed, sizeof(removed));
|
|
CHECK(st == REG_OK, "deregister A ok");
|
|
CHECK(strcmp(removed, "ornith-A") == 0, "collect reports the removed id");
|
|
|
|
/* Sweep the remaining one with a future clock; it must be reported. */
|
|
char evicted[8][RW_INSTANCE_ID_MAX];
|
|
int reported = 0;
|
|
time_t now = time(NULL);
|
|
int ev = reg_sweep_collect(&r, now + RW_TTL_SECONDS + 1, evicted, 8, &reported);
|
|
CHECK(ev == 1, "one instance swept");
|
|
CHECK(reported == 1, "one evicted id reported");
|
|
CHECK(strcmp(evicted[0], "ornith-B") == 0, "reported id is ornith-B");
|
|
|
|
reg_destroy(&r);
|
|
}
|
|
|
|
/* --- integration: affinity across the pick path ---------------------------- */
|
|
|
|
/* Mirror the router's pick logic: session bound + live → reuse; else least-busy
|
|
* and record. Proves same-session stickiness and dead-instance re-route. */
|
|
static void test_affinity_pick_flow(void)
|
|
{
|
|
printf("affinity: same session sticks; dead instance re-routes\n");
|
|
reg_registry r;
|
|
session_affinity sa;
|
|
CHECK(reg_init(&r) == 0, "reg_init");
|
|
CHECK(sa_init(&sa) == 0, "sa_init");
|
|
reg_one(&r, "ornith", "ornith-A", "http://127.0.0.1:8201", 4);
|
|
reg_one(&r, "ornith", "ornith-B", "http://127.0.0.1:8202", 4);
|
|
|
|
char bound[RW_INSTANCE_ID_MAX];
|
|
reg_instance pick;
|
|
|
|
/* Turn 1: new session → least-busy, recorded. */
|
|
CHECK(!sa_lookup(&sa, "chat-1", bound, sizeof(bound)), "turn1 no binding yet");
|
|
CHECK(reg_pick_least_busy(&r, "ornith", NULL, 0, &pick), "turn1 least-busy");
|
|
sa_record(&sa, "chat-1", pick.instance_id);
|
|
char first_inst[RW_INSTANCE_ID_MAX];
|
|
snprintf(first_inst, sizeof(first_inst), "%s", pick.instance_id);
|
|
|
|
/* Turn 2..5: same session → SAME instance (KV reuse). */
|
|
for (int t = 0; t < 4; t++) {
|
|
CHECK(sa_lookup(&sa, "chat-1", bound, sizeof(bound)), "turn2+ has a binding");
|
|
CHECK(reg_find_live(&r, "ornith", bound, &pick), "bound instance live");
|
|
CHECK(strcmp(pick.instance_id, first_inst) == 0,
|
|
"same session lands on the same instance");
|
|
}
|
|
|
|
/* The bound instance dies (deregister) and its binding is forgotten. */
|
|
char removed[RW_INSTANCE_ID_MAX];
|
|
char body[128];
|
|
snprintf(body, sizeof(body), "{\"instance_id\":\"%s\"}", first_inst);
|
|
reg_deregister_json_collect(&r, body, removed, sizeof(removed));
|
|
sa_forget_instance(&sa, removed);
|
|
|
|
/* Next turn: no binding → least-busy picks the surviving instance, recorded. */
|
|
CHECK(!sa_lookup(&sa, "chat-1", bound, sizeof(bound)),
|
|
"binding cleared after its instance died");
|
|
CHECK(reg_pick_least_busy(&r, "ornith", NULL, 0, &pick), "re-route least-busy");
|
|
CHECK(strcmp(pick.instance_id, first_inst) != 0, "re-routed to a live instance");
|
|
sa_record(&sa, "chat-1", pick.instance_id);
|
|
CHECK(sa_lookup(&sa, "chat-1", bound, sizeof(bound)), "re-route recorded");
|
|
|
|
sa_destroy(&sa);
|
|
reg_destroy(&r);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
test_map_basic();
|
|
test_map_lru_eviction();
|
|
test_map_forget_instance();
|
|
test_reg_find_live();
|
|
test_reg_collect_variants();
|
|
test_affinity_pick_flow();
|
|
|
|
if (g_failed) {
|
|
fprintf(stderr, "\n%d CHECK(s) FAILED\n", g_failed);
|
|
return 1;
|
|
}
|
|
printf("ALL SESSION-AFFINITY UNIT TESTS PASSED\n");
|
|
return 0;
|
|
}
|