Skip to content
arielb57Public

About

Check FIX session logs against the sequence-recovery rules and name the exact rule broken

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

fixgap

Check FIX session logs against the sequence-recovery rules and name the exact rule broken.

The problem

After a disconnect, two FIX counterparties recover with ResendRequest, SequenceReset-GapFill and PossDupFlag replay. When that goes wrong an order is lost or executed twice, and the ops engineer on call works it out by reading raw tag=value logs by eye. Every engine enforces these rules on its own traffic, but none of them will take a log from someone else's engine and tell you whether it conformed, and certification suites run canned scripts rather than the log from the incident. fixgap reads the log you actually have and says which rule broke, at which message, with the expected and the actual value.

How it works

fixgap is an executable model of the FIX 4.2/4.4 session layer that is replayed over a timestamped transcript written from one endpoint's point of view:

2026-01-02T09:00:00.000Z EVENT connect
2026-01-02T09:00:00.000Z OUT 8=FIX.4.4|9=69|35=A|49=BUYSIDE|56=SELLSIDE|34=1|52=20260102-09:00:00.000|98=0|108=4|10=038|
2026-01-02T09:00:00.058Z IN 8=FIX.4.4|9=69|35=A|49=SELLSIDE|56=BUYSIDE|34=1|52=20260102-09:00:00.025|98=0|108=4|10=045|
2026-01-02T09:00:03.659Z IN 8=FIX.4.4|9=67|35=2|49=SELLSIDE|56=BUYSIDE|34=7|52=20260102-09:00:03.628|7=6|16=6|10=202|

OUT is what the log owner sent, IN is what it received, EVENT connect|disconnect marks connection boundaries. The delimiter may be SOH or |.

The pipeline has three stages.

  1. Framer. Splits tag=value fields, checks the header order (8, 9, 35), that CheckSum(10) is last, and recomputes BodyLength(9) as a UTF-8 byte count and CheckSum(10) as the byte sum mod 256. A | in the log is counted as the SOH byte, so a pretty-printed log checks the same way as the wire bytes.

  2. Session model. The two directions are modelled asymmetrically, because the log owner knows different things about each:

    • Outbound is checked exactly. The owner logged everything it sent, so the model keeps the next outbound MsgSeqNum, a store of originals per sequence epoch, and a queue of owed replays. Each inbound ResendRequest pushes a range [BeginSeqNo, min(EndSeqNo, last sent)] (EndSeqNo=0 means "through the last sent"). Replay messages must walk a cursor through the head range contiguously; a GapFill advances the cursor to NewSeqNo. A new message sent while a range is still owed is a RESEND-HOLE. Overlapping requests queue up, and a later request that covers the rest of the current one may take over. A disconnect cancels the queue, because the peer asks again after the next Logon.
    • Inbound is a seen-set. The network may have dropped messages, so a gap on its own is not the counterparty's fault. The model records which sequence slots have been seen or gap-filled and keeps a contiguous low-water mark. A slot seen twice without PossDupFlag is SEQ-TOO-LOW. A PossDup copy whose body differs from the original is RESEND-PAYLOAD-MISMATCH. The model does not assume whether the receiver queues or discards out-of-order messages.
    • Resets. A Logon with ResetSeqNumFlag=Y must carry MsgSeqNum 1 and be answered by a Logon with the flag set. Either side's reset opens a new epoch and cancels owed replays. Messages still arriving under the old numbering after we sent our reset Logon are ignored until the reset is acknowledged: in-flight traffic crosses the reset legitimately.
    • Timers. HeartBtInt(108) from the owner's Logon gives the outbound silence deadline. An inbound TestRequest opens a deadline for a Heartbeat carrying the same TestReqID(112).
  3. Delivery ledger. Every application message (any MsgType that is not 0, 1, 2, 3, 4, 5 or A) gets one entry per direction, epoch and sequence number. Its status is one of:

    • exactly-once
    • lost: the peer asked for it and it was gap filled, skipped, or abandoned by a reset.
    • at-most-once: nothing in the log proves the peer saw it. Either no inbound traffic followed it before a reset or the end of the log, or it arrived out of order and was later gap filled.
    • duplicated: the same ClOrdID(11) or ExecID(17) was sent under two sequence numbers without PossResend.

    Lost and duplicated entries also produce LEDGER-SILENT-LOSS and LEDGER-DUPLICATE warnings.

Every finding carries a stable rule ID, the message index (0-based, events not counted), the line number, the direction (OUT means the log owner broke the rule, IN means the counterparty did), and the expected and actual values.

Reference simulator

simulate(seed) runs two conforming FIX 4.4 endpoints (an initiator and an acceptor) over a seeded, discrete-event channel with:

  • random message loss (0 to 15%)
  • bounded reordering: jitter smaller than the base delay, so a retransmission can never overtake its original
  • random disconnects, including while a resend is in flight
  • in-session resets from either side, and resets on reconnect
  • heartbeats, TestRequests, and ResendRequest retry timers

Both endpoints write their own log. Resets happen only after the two sides have synchronised, the way an end-of-day reset is done. The simulator also records how many times each application layer processed each business message. That is ground truth the checker never sees.

Rule catalogue

Rule ID Severity What it means Spec reference
FRAME-BODYLENGTH error BodyLength(9) differs from the byte count between tag 9 and CheckSum(10). FIX 4.2/4.4 Vol. 1, Message Format: BodyLength
FRAME-CHECKSUM error CheckSum(10) differs from the sum of all preceding bytes mod 256. FIX 4.2/4.4 Vol. 1, Message Format: CheckSum calculation
FRAME-MALFORMED error Header is not 8, 9, 35 in order, CheckSum(10) is not last, a field is not tag=value, or MsgSeqNum(34) is missing. FIX 4.2/4.4 Vol. 1, Standard Message Header and Trailer
SESS-LOGON-FIRST error The first message in a direction on a connection is not a Logon. FIX 4.4 Vol. 2, Session Protocol: Logon
SESS-RESET-SEQNUM error A Logon with ResetSeqNumFlag(141)=Y does not carry MsgSeqNum 1. FIX 4.4 Vol. 2, Logon: ResetSeqNumFlag
SESS-RESET-ACK error The Logon answering a ResetSeqNumFlag=Y Logon does not itself carry ResetSeqNumFlag=Y. FIX 4.4 Vol. 2, Logon: ResetSeqNumFlag
SEQ-GAP-OUTBOUND error The log owner sent a new message whose MsgSeqNum skips numbers it never sent. FIX 4.4 Vol. 2, Message Sequencing
SEQ-TOO-LOW error A message reuses an already-used MsgSeqNum without PossDupFlag(43)=Y. FIX 4.4 Vol. 2, Message Recovery: MsgSeqNum lower than expected
SEQRESET-BACKWARD error SequenceReset NewSeqNo(36) does not move forward (GapFill: not above its own MsgSeqNum; Reset: below the expected number). FIX 4.4 Vol. 2, Sequence Reset (Gap Fill) and Sequence Reset (Reset)
RESEND-HOLE error A ResendRequest was not answered with a contiguous replay of the requested range before new messages were sent. FIX 4.4 Vol. 2, Resend Request and Message Recovery
RESEND-NO-POSSDUP error A replayed message answering a ResendRequest lacks PossDupFlag(43)=Y. FIX 4.4 Vol. 2, Possible Duplicates
RESEND-NO-ORIGSENDINGTIME error A PossDupFlag=Y retransmission lacks OrigSendingTime(122). FIX 4.4 Vol. 1, Standard Message Header: OrigSendingTime
RESEND-ADMIN-REPLAYED error A session-level message other than Reject(3) was retransmitted instead of being covered by SequenceReset-GapFill. Reject is resent, because the peer needs the Reject itself. FIX 4.4 Vol. 2, Resend Request: administrative messages are gap filled
RESEND-PAYLOAD-MISMATCH error A retransmission of a MsgSeqNum carries a different body from the original. FIX 4.4 Vol. 2, Possible Duplicates
HB-DEADLINE error The log owner was silent for longer than HeartBtInt(108) plus the configured slack. FIX 4.4 Vol. 2, Heartbeat interval
HB-TESTREQ-UNANSWERED error A TestRequest was not answered by a Heartbeat with the same TestReqID(112) within the slack. FIX 4.4 Vol. 2, Test Request
RECOVERY-GAP-UNREQUESTED warning The log ends with an inbound sequence gap the log owner never asked to be resent. FIX 4.4 Vol. 2, Message Recovery
LEDGER-SILENT-LOSS warning An application message the peer asked for was gap filled, skipped, or abandoned by a reset. FIX 4.4 Vol. 2, Message Recovery (application-level consequence)
LEDGER-DUPLICATE warning The same ClOrdID(11) or ExecID(17) was sent under two distinct, non-PossDup sequence numbers. FIX 4.4 Vol. 2, Possible Duplicates vs. Possible Resends (PossResend(97))

fixgap rules prints the same catalogue. The spec references name sections of the FIX 4.4 specification volumes; they are not page numbers.

Install and usage

Requires Node.js 22 or newer. There are no runtime dependencies. TypeScript is the only build dependency.

git clone <this repository> fixgap
cd fixgap
npm ci
npm run build
npm link            # optional: puts `fixgap` on your PATH; otherwise use `node dist/src/bin.js`

Generate a conversation with loss and disconnects, then check one side of it:

$ fixgap simulate --seed 25 --out logs
seed 25: HeartBtInt 4s, loss 9.5%
  application messages A->B 21, B->A 6
  dropped 7, disconnects 3 (2 mid-resend), resets in-session 0 / on reconnect 0
  resend requests 6, gap fills 4, retransmissions 21
wrote logs/sim-25-A.fix
wrote logs/sim-25-B.fix

$ fixgap check logs/sim-25-A.fix --heartbeat-slack 2s
logs/sim-25-A.fix: 69 messages, 4 connection(s), heartbeat slack 2000ms

violations: none

delivery ledger: 27 application messages
  exactly-once 27 | at-most-once 0 | duplicated 0 | lost 0

Inject a known fault and check again:

$ fixgap mutate logs/sim-25-A.fix drop-gapfill --seed 4 > bad.fix
fixgap: applied drop-gapfill; expect RESEND-HOLE at message #48

$ fixgap check bad.fix --heartbeat-slack 2s
bad.fix: 68 messages, 4 connection(s), heartbeat slack 2000ms

violations (1):
  #48 line 54 OUT RESEND-HOLE (error)
      expected: MsgSeqNum 13
      actual:   MsgSeqNum 14
      replay is not contiguous

delivery ledger: 27 application messages
  exactly-once 27 | at-most-once 0 | duplicated 0 | lost 0
$ echo $?
1

Options for check:

  • --heartbeat-slack <duration>: lateness allowed beyond HeartBtInt, and the deadline for answering a TestRequest. The default is 1s. Accepts 2s, 500ms, 1.5s.
  • --ledger: list every ledger entry, not only the ones that are not exactly-once.
  • --json: print { violations, ledger } as JSON.

Exit codes: 0 when there is no error-severity violation (warnings allowed), 1 when there is at least one, 2 for bad input.

Mutation kinds for fixgap mutate: drop-gapfill, lower-newseqno, replay-without-possdup, resend-admin, late-heartbeat, corrupt-bodylength, corrupt-checksum, duplicate-seq-different-payload.

Library

import { readFileSync } from 'node:fs';
import { parseTranscript, checkSession, deliveryLedger } from 'fixgap';

const transcript = parseTranscript(readFileSync('session.fix', 'utf8'));
const { violations, ledger } = checkSession(transcript, { heartbeatSlackMs: 2000 });
for (const v of violations) console.log(v.rule, v.index, v.expected, v.actual);
console.log(deliveryLedger(transcript).filter((e) => e.status !== 'exactly-once'));

simulate(seed), mutate(log, kind, rng), frame(raw), encode(...) and RULES are exported too.

Results

npm test runs 52 tests. On the author's macOS laptop with Node 24.12, the whole suite takes about 9 seconds. The two claims that matter are tested like this:

  • No false positives on conforming traffic. Seeds 1 to 10,000 are simulated and both endpoints' logs are checked, which is 20,000 transcripts. The test requires:

    • zero violations of any severity;
    • every ledger entry exactly-once;
    • the ledger's keys equal to the business messages each side actually sent;
    • the simulator's own application layer having processed each of them exactly once.

    The test also fails if the corpus stops exercising recovery, via minimum counts of dropped messages, disconnects, mid-resend disconnects, resets of both kinds, gap fills and retransmissions.

  • Each injected fault is reported exactly. Every mutation operator is applied to both logs of seeds 1 to 1,000. For every mutated log, the checker must report exactly one violation, with the expected rule ID at the expected index. Each operator must find a place to apply at least 150 times.

Hand-written cases cover:

  • ResendRequest with EndSeqNo=0, answered completely, stopped early, and with a skipped number
  • overlapping ResendRequests, answered in turn, taken over by a covering request, and partially ignored
  • a GapFill covering the entire request, over admin messages (conforming) and over orders (silent loss)
  • a counterparty Logon-with-reset crossing an owed replay
  • our own reset crossing the counterparty's in-flight retransmissions
  • every other rule in the catalogue

The framer is checked against a published FIX 4.2 message (BodyLength 65, CheckSum 062).

There is no performance claim and no benchmark. For reference, checking the 20,000 simulated transcripts takes under 10 seconds.

Design notes

One fault, one report. A single real fault usually breaks several invariants at once. A GapFill whose NewSeqNo goes backward leaves the replay cursor behind, so the next replay message looks non-contiguous and the next new message looks like it abandoned the request. A checker that reports all three buries the cause. After a sequence violation on the outbound stream, fixgap sets a desync flag: the next outbound message re-anchors the cursor (or clears the owed range) silently, and then normal checking resumes. The mutation tests demand exactly one violation per injected fault, which keeps that suppression honest. The cost is that a second, independent fault on the very next outbound message can be masked.

Exact outbound, set-based inbound. The tempting design runs the same receiver state machine on both directions. That fails on real logs, because the log owner's view of inbound traffic already has network loss in it, and receivers differ in whether they queue or discard out-of-order messages. Modelling the inbound side as a seen-set with a contiguous low-water mark makes the checker agnostic to receiver strategy. The price is weaker inbound checks: the counterparty is only blamed for what is provable from what arrived (reused numbers, bad payloads, admin replays, backward resets), and delivery of our own messages is inferred rather than proven. That is why the ledger has an at-most-once status instead of pretending to certainty.

Limitations

  • One log at a time. Outbound delivery is inferred: a message the peer never asked for again counts as delivered once inbound traffic follows it. fixgap cannot merge both counterparties' logs to prove receipt.
  • Log order is taken as processing order. A multi-threaded engine that logs a ResendRequest and then flushes already-queued new messages before starting the replay will get a RESEND-HOLE.
  • Replays must precede new messages. The model expects the whole requested range to be replayed before any new message. Engines that interleave new traffic with a long replay are reported.
  • Out of scope: SequenceReset-Reset is handled but not exercised by the simulator. NextExpectedMsgSeqNum(789), FIXT.1.1 and repeating groups are not modelled. Session-level Reject(3) is treated as an admin message that must be gap filled (the QuickFIX default); engines configured to resend Rejects will be reported.
  • Heartbeat checks cover the log owner only. Counterparty silence is not flagged, because its heartbeats may have been lost in transit.
  • No SOH-in-data handling. Data fields containing the delimiter (RawData, XmlData, or | inside a value in a pipe-delimited log) are not supported; the length-prefixed data tags are not special-cased.
  • The only accepted log format is the one above (ISO-8601 timestamp, direction, raw message). Engine-specific formats, such as QuickFIX's separate incoming and outgoing files, need a small conversion script first.
  • The simulator's endpoints are conforming by construction and share the checker's reading of the spec. A misreading of the spec present in both would not be caught by the simulation test. The hand-written cases are the guard against that, and they are only as good as the author's reading.

License

MIT. See LICENSE.

About

Check FIX session logs against the sequence-recovery rules and name the exact rule broken

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages