181 lines
5.9 KiB
TypeScript
181 lines
5.9 KiB
TypeScript
/**
|
|
* DMP-09 / DMP-10 — Generation counter monotonicity; advertisement throttling.
|
|
*
|
|
* `GenerationTracker` keeps the highest generation per `(peerId, fileId)`
|
|
* and drops stale advertisements; it also accepts a one-cycle wrap to
|
|
* tolerate a single anomaly past 2^31. `AdvertiseThrottle` limits
|
|
* outgoing advertisements to one per `(neighbor, fileId)` per `windowMs`.
|
|
*/
|
|
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
ADVERTISE_THROTTLE_MS_DEFAULT,
|
|
ADVERTISE_THROTTLE_MS_MAX,
|
|
AdvertiseThrottle,
|
|
DMP_CODES,
|
|
DmpError,
|
|
GENERATION_WRAP_THRESHOLD,
|
|
GenerationTracker,
|
|
} from "../src/index.js";
|
|
|
|
const PEER = "peer-A";
|
|
const FILE = "F".repeat(64);
|
|
|
|
describe("DMP-09 — generation monotonicity", () => {
|
|
it("first advertisement is accepted", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
const r = t.observe(PEER, FILE, 7);
|
|
expect(r.accept).toBe(true);
|
|
expect(r.latest).toBe(7);
|
|
});
|
|
|
|
it("higher generation is accepted", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
t.observe(PEER, FILE, 7);
|
|
const r = t.observe(PEER, FILE, 8);
|
|
expect(r.accept).toBe(true);
|
|
expect(r.latest).toBe(8);
|
|
});
|
|
|
|
it("out-of-order advertisement is dropped", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
t.observe(PEER, FILE, 10);
|
|
const r = t.observe(PEER, FILE, 5);
|
|
expect(r.accept).toBe(false);
|
|
expect(r.reason).toBe("stale");
|
|
expect(t.peek(PEER, FILE)).toBe(10);
|
|
});
|
|
|
|
it("duplicate generation is dropped", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
t.observe(PEER, FILE, 4);
|
|
const r = t.observe(PEER, FILE, 4);
|
|
expect(r.accept).toBe(false);
|
|
expect(r.reason).toBe("duplicate");
|
|
});
|
|
|
|
it("wrap detected past 2^31 delta", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
const high = 0xff_ff_ff_ff;
|
|
t.observe(PEER, FILE, high);
|
|
const r = t.observe(PEER, FILE, 0);
|
|
expect(r.accept).toBe(true);
|
|
expect(r.latest).toBe(0);
|
|
expect(GENERATION_WRAP_THRESHOLD).toBe(2 ** 31);
|
|
});
|
|
|
|
it("negative or non-integer generation rejected", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
expect(t.observe(PEER, FILE, -1).accept).toBe(false);
|
|
expect(t.observe(PEER, FILE, 1.5).accept).toBe(false);
|
|
});
|
|
|
|
it("dropPeer wipes that peer's entries", { timeout: 5000 }, () => {
|
|
const t = new GenerationTracker();
|
|
t.observe(PEER, FILE, 5);
|
|
t.observe("other", FILE, 9);
|
|
t.dropPeer(PEER);
|
|
expect(t.peek(PEER, FILE)).toBeUndefined();
|
|
expect(t.peek("other", FILE)).toBe(9);
|
|
});
|
|
|
|
it("dropFile wipes the file's entries across peers", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const t = new GenerationTracker();
|
|
t.observe(PEER, FILE, 5);
|
|
t.observe(PEER, "other", 9);
|
|
t.dropFile(FILE);
|
|
expect(t.peek(PEER, FILE)).toBeUndefined();
|
|
expect(t.peek(PEER, "other")).toBe(9);
|
|
});
|
|
});
|
|
|
|
describe("DMP-10 — advertisement throttle", () => {
|
|
it("first call is allowed", { timeout: 5000 }, () => {
|
|
const now = 0;
|
|
const throttle = new AdvertiseThrottle({
|
|
clock: () => now,
|
|
windowMs: ADVERTISE_THROTTLE_MS_DEFAULT,
|
|
});
|
|
const r = throttle.allow("neighbor-1", FILE);
|
|
expect(r.allow).toBe(true);
|
|
expect(r.retryAfterMs).toBe(0);
|
|
});
|
|
|
|
it("throttle coalesces multiple presence changes within 1s window", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
let now = 0;
|
|
const throttle = new AdvertiseThrottle({
|
|
clock: () => now,
|
|
windowMs: 1_000,
|
|
});
|
|
// First emission allowed.
|
|
expect(throttle.allow("n", FILE).allow).toBe(true);
|
|
now = 200;
|
|
// Within the window — suppressed and pending.
|
|
const r2 = throttle.allow("n", FILE);
|
|
expect(r2.allow).toBe(false);
|
|
expect(r2.retryAfterMs).toBeGreaterThan(0);
|
|
expect(throttle.hasPending("n", FILE)).toBe(true);
|
|
now = 500;
|
|
// Still in window.
|
|
expect(throttle.allow("n", FILE).allow).toBe(false);
|
|
now = 1_001;
|
|
// Window has elapsed; allowed again.
|
|
const r4 = throttle.allow("n", FILE);
|
|
expect(r4.allow).toBe(true);
|
|
expect(throttle.hasPending("n", FILE)).toBe(false);
|
|
});
|
|
|
|
it("clearPending resets the pending bit", { timeout: 5000 }, () => {
|
|
let now = 0;
|
|
const t = new AdvertiseThrottle({
|
|
clock: () => now,
|
|
windowMs: 1_000,
|
|
});
|
|
t.allow("n", FILE);
|
|
now = 100;
|
|
t.allow("n", FILE);
|
|
expect(t.hasPending("n", FILE)).toBe(true);
|
|
t.clearPending("n", FILE);
|
|
expect(t.hasPending("n", FILE)).toBe(false);
|
|
});
|
|
|
|
it("dropNeighbor evicts only that neighbor's state", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const t = new AdvertiseThrottle({
|
|
clock: () => 0,
|
|
windowMs: 1_000,
|
|
});
|
|
t.allow("a", FILE);
|
|
t.allow("b", FILE);
|
|
t.dropNeighbor("a");
|
|
expect(t.size).toBe(1);
|
|
});
|
|
|
|
it("rejects windowMs ≤ 0 or > 60s with THROTTLE_EXCEEDED", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
try {
|
|
new AdvertiseThrottle({ clock: () => 0, windowMs: 0 });
|
|
throw new Error("expected rejection");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(DmpError);
|
|
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
|
}
|
|
try {
|
|
new AdvertiseThrottle({
|
|
clock: () => 0,
|
|
windowMs: ADVERTISE_THROTTLE_MS_MAX + 1,
|
|
});
|
|
throw new Error("expected rejection");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(DmpError);
|
|
expect((err as DmpError).code).toBe(DMP_CODES.THROTTLE_EXCEEDED);
|
|
}
|
|
});
|
|
});
|