5b7b563dc2
* Regression tests for answering every inbound segment as it arrives * Answer every segment of an inbound concatenated message as it arrives * Record the segment-by-segment answer in AGENTS.md and README * Regression tests for an empty message_id on deliver_sm_resp * Answer a deliver_sm with the empty message_id SMPP 3.4 makes it * Record Jasmin's refusal of a deliver_sm_resp message_id * Mark the Jasmin multipart deadlock fixed * Regression tests for the architecture review's findings * Give the segment id notation an owner, and every segment a status * Correct what the ids reach and what a lost group tells the application * Regression tests for a group lost to its own octet overrun * Report a group lost to its own overrun, and refuse by the command it arrived on * Keep the docs true about what a segment is answered with * Count only the answered segments of a group lost to an overrun * Report only what a lost group cost, and say which cap bit
181 lines
5.0 KiB
TypeScript
181 lines
5.0 KiB
TypeScript
import type { Dlr } from './dlr.ts';
|
|
import type { MessageState } from './defs/constants.ts';
|
|
import type { SmppLog } from './log.ts';
|
|
import { ExpiringGroups } from './expiring-groups.ts';
|
|
import { parseSegmentId } from './sms-id.ts';
|
|
|
|
export type MessageDlr = Dlr & { segments: Dlr[]; smsId: string };
|
|
|
|
export type DlrMergerOptions = {
|
|
log: SmppLog;
|
|
max: number;
|
|
/** Injected so expiry can be exercised without a wall clock. */
|
|
now?: (() => number) | undefined;
|
|
timeout: number;
|
|
};
|
|
|
|
type Group = {
|
|
expected: Set<number>;
|
|
parts: Map<number, Dlr>;
|
|
};
|
|
|
|
/**
|
|
* MESSAGE_STATE is a flat enum, not a ranking — ACCEPTED is 6 where UNDELIVERABLE is 5 — so reducing
|
|
* on the wire value reports a part-failed message as delivered. Rank it deliberately instead.
|
|
*/
|
|
const severity: Record<MessageState, number> = {
|
|
DELIVERED: 0,
|
|
ACCEPTED: 1,
|
|
ENROUTE: 2,
|
|
SCHEDULED: 3,
|
|
SKIPPED: 4,
|
|
UNKNOWN: 5,
|
|
EXPIRED: 6,
|
|
DELETED: 7,
|
|
REJECTED: 8,
|
|
UNDELIVERABLE: 9,
|
|
};
|
|
|
|
/** The base and the part numbers one send's ids carry, or nothing when they do not spell out one message. */
|
|
function idNumbering(smsIds: string[]): { base: string; parts: Set<number> } | undefined {
|
|
const bases = new Set<string>();
|
|
const parts = new Set<number>();
|
|
|
|
for (const smsId of smsIds) {
|
|
const numbering = parseSegmentId(smsId);
|
|
|
|
if (!numbering) return undefined;
|
|
|
|
bases.add(numbering.base);
|
|
parts.add(numbering.part);
|
|
}
|
|
|
|
const [base] = bases;
|
|
|
|
// A repeated id leaves a part no receipt can fill, so the group would complete on a short set.
|
|
if (!base || bases.size !== 1 || parts.size !== smsIds.length) return undefined;
|
|
|
|
return { base, parts };
|
|
}
|
|
|
|
/**
|
|
* Merges the per-segment receipts of a multipart message into one report, but only when the peer
|
|
* numbered its ids `<base>-<n>` off one base — the convention this library's own server follows. An
|
|
* SMSC that hands out unrelated ids per segment cannot be merged, so nothing is reported for it.
|
|
* A base is merged at most once: a reused id cannot be told apart from a straggler, and a report the
|
|
* peer marked intermediate is never counted — it would fill a slot before the real receipt arrives.
|
|
*/
|
|
export class DlrMerger {
|
|
private readonly groups: ExpiringGroups<Group>;
|
|
private readonly log: SmppLog;
|
|
private readonly max: number;
|
|
private readonly spent: ExpiringGroups<true>;
|
|
|
|
constructor(options: DlrMergerOptions) {
|
|
this.groups = new ExpiringGroups<Group>({
|
|
max: options.max,
|
|
now: options.now,
|
|
onSweep: () => { this.sweep(); },
|
|
timeout: options.timeout,
|
|
});
|
|
this.log = options.log;
|
|
this.max = options.max;
|
|
this.spent = new ExpiringGroups<true>({
|
|
max: options.max,
|
|
now: options.now,
|
|
onSweep: () => { this.spent.takeExpired(); },
|
|
timeout: options.timeout,
|
|
});
|
|
}
|
|
|
|
get size(): number {
|
|
return this.groups.size;
|
|
}
|
|
|
|
/** Registers the ids one multipart send got back, so their receipts can be merged. */
|
|
expect(smsIds: string[]): void {
|
|
if (smsIds.length < 2) return;
|
|
|
|
const numbering = idNumbering(smsIds);
|
|
|
|
if (numbering) this.open(numbering.base, numbering.parts);
|
|
}
|
|
|
|
/** The whole message's report, on the receipt that completes it. */
|
|
collect(dlr: Dlr): MessageDlr | undefined {
|
|
this.sweep();
|
|
|
|
if (dlr.intermediate || dlr.smsId === undefined) return undefined;
|
|
|
|
const numbering = parseSegmentId(dlr.smsId);
|
|
|
|
if (!numbering) return undefined;
|
|
|
|
const { base, part: number } = numbering;
|
|
const group = this.groups.get(base);
|
|
|
|
if (!group) return undefined;
|
|
|
|
if (!group.expected.has(number)) return undefined;
|
|
|
|
group.parts.set(number, dlr);
|
|
|
|
if (group.parts.size < group.expected.size) return undefined;
|
|
|
|
this.close(base);
|
|
|
|
const segments = [...group.parts.entries()].sort(([a], [b]) => a - b).map(([, one]) => one);
|
|
const worst = segments.reduce((carry, one) => (severity[one.statusMsg] > severity[carry.statusMsg] ? one : carry));
|
|
|
|
return { ...worst, segments, smsId: base };
|
|
}
|
|
|
|
clear(): void {
|
|
this.groups.takeAll();
|
|
this.spent.takeAll();
|
|
}
|
|
|
|
/** Drops every group past its deadline. Runs before each collect and on its own timer. */
|
|
sweep(): void {
|
|
for (const [base, group] of this.groups.takeExpired()) {
|
|
this.close(base);
|
|
this.log.info('dlrMerger - incomplete receipts expired', { base, expected: group.expected.size });
|
|
}
|
|
}
|
|
|
|
private open(base: string, expected: Set<number>): void {
|
|
this.spent.takeExpired();
|
|
|
|
if (this.groups.get(base) !== undefined || this.spent.get(base) === true) {
|
|
this.close(base);
|
|
this.log.info('dlrMerger - message id handed out again, leaving its receipts unmerged', { base });
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.groups.full) this.dropOldest();
|
|
|
|
this.groups.set(base, { expected, parts: new Map() });
|
|
}
|
|
|
|
private close(base: string): void {
|
|
this.groups.delete(base);
|
|
this.spent.delete(base);
|
|
|
|
if (this.spent.full) this.spent.takeOldest();
|
|
|
|
this.spent.set(base, true);
|
|
}
|
|
|
|
private dropOldest(): void {
|
|
const oldest = this.groups.takeOldest();
|
|
|
|
if (!oldest) return;
|
|
|
|
const [base] = oldest;
|
|
|
|
this.close(base);
|
|
this.log.warn('dlrMerger - buffer full, dropping the oldest message', { base, max: this.max });
|
|
}
|
|
}
|