Recordizer: batching and record boundaries
What PACKET_MUX_V1 does before encryption, how it is negotiated after AUTH, and how to safely configure batching, record size, fragmentation and reassembly.
The recordizer changes the IP-packet-to-record mapping
In the legacy data plane, one inner IPv4/IPv6 packet usually became one encrypted record. That does not reveal content but creates a stable size/timing relationship. Negotiated PACKET_MUX_V1 works before encryption:
- several inner packets can share one encrypted record;
- one inner packet can span several records;
- target size and batching deadline vary within configured bounds;
- mux headers are inside AEAD and invisible on the wire.
Where it runs
inner IPv4/IPv6 packet
→ PACKET_MUX recordizer
→ padding / traffic normalization
→ PacketCodec encryption
→ carrier (plain / fake-TLS / Reality-H2 / obfs-WS / UDP QUIC shape)
→ carrier/path fragmentation if required
Target record size is a batching threshold, not a promise to pad every record to that size. A deadline may flush a smaller record. Padding and traffic normalization run after the recordizer.
The server owns the policy
obf.recordizer.* is not a client setting and is not added to qeli://. The server sends the policy in the authenticated AUTH contract; changes apply after reconnect.
| Policy | Behavior | Rollout |
|---|---|---|
off | Legacy mapping: one inner packet roughly matches one record. | Rollback or troubleshooting. |
prefer | Enables PACKET_MUX_V1 only when the client advertises it; a legacy client stays on the legacy plane. | Recommended first stage and shipped-template default. |
required | Rejects an incompatible client before address allocation. | Only after the whole fleet is upgraded. |
Recommended 0.8.1 settings
obf.recordizer.policy = prefer
obf.recordizer.batch.delay_min_ms = 2
obf.recordizer.batch.delay_max_ms = 8
obf.recordizer.batch.max_packets = 16
obf.recordizer.batch.max_queue_bytes = 262144
obf.recordizer.record.max_payload_bytes = 0
obf.recordizer.record.small_min_ratio = 0.25
obf.recordizer.record.small_max_ratio = 0.875
obf.recordizer.record.full_probability = 0.72
obf.recordizer.fragment.enabled = true
obf.recordizer.fragment.reassembly_timeout_ms = 3000
obf.recordizer.fragment.max_inflight_packets = 64
obf.recordizer.fragment.max_reassembly_bytes = 4194304
obf.recordizer.fragment.max_fragments_per_packet = 64
Batching, size and reassembly
| Group | Keys | Meaning and limits |
|---|---|---|
| Deadline | delay_min_msdelay_max_ms | Random flush window started by the first queued packet. 0/0 sends immediately; max must not be below min. |
| Queue | max_packetsmax_queue_bytes | Maximum mux frames and hard per-direction memory limit. Queue bytes: 64…4194304. |
| Payload ceiling | max_payload_bytes | 0 derives safe plaintext from active carrier/path; explicit values are 64…MAX_TUNNEL_MTU and remain budget-capped. |
| Morphology | small_min_ratiosmall_max_ratiofull_probability | Small target uses 0 < min ≤ max ≤ 1; probability is 0…1 and selects a full target. |
| Fragment | fragment.enabled | Allows one inner packet to span records. Do not disable unless every packet is guaranteed to fit. |
| Reassembly | reassembly_timeout_msmax_inflight_packetsmax_reassembly_bytesmax_fragments_per_packet | Bound time, packet IDs, total memory and fragments per packet for each direction. |
Three safe starting points
| Scenario | Changes | Trade-off |
|---|---|---|
| Latency-sensitive | delay=0…2 ms, max_packets=4 | Less batching latency, weaker regrouping. |
| Lossy UDP | delay=0…2 ms, max_packets=2, full_probability=0.85 | Less loss amplification from one large datagram. |
| Experimental morphology | delay=3…12 ms, full_probability=0.55 | More variability but greater latency/overhead; test first. |
Interaction with other features
- Works with all TCP/UDP modes, IPv4/IPv6/dual stack and TUN/TAP. TAP strips/restores Ethernet headers at the edge; arbitrary L2 frames are not carried.
- Reality/H2 batching and recordizer delay can add together. Change one dimension at a time.
- Large UDP batches amplify datagram loss; prefer a short window and small
max_packets. - AWG junk runs before handshake; the recordizer starts after AUTH. QUIC shape is still not real QUIC.
obf.fragmentation.*splits selected handshake/carrier writes and does not replaceobf.recordizer.fragment.*.- UDP DATA_FRAG/PMTU is outer fragmentation after encryption; recordizer fragmentation splits the inner packet before PacketCodec.
Batching system calls does not create a new record format
After Recordizer datagrams are ready, Linux and Android send up to 32 items with one sendmmsg; already queued inbound datagrams are read with recvmmsg. There is no extra coalescing delay, and neither the wire format nor obf.recordizer.* settings change.
| Same-window A/B | Before | After | Change |
|---|---|---|---|
| Upload | 320.8 Mbit/s | 694.7 Mbit/s | +116.5% |
| Download | 359.8 Mbit/s | 697.5 Mbit/s | +93.9% |
Verification and rollback
- Upgrade the server, set
policy=prefer, runqeli check-configand restart the profile. - Reconnect the client and find
Packet recordizer: PACKET_MUX_V1 activein logs. - Test bidirectional IPv4/IPv6, DNS, reconnect/roaming, PMTU and sustained load on every used transport.
- Optionally move to
requiredafter the whole fleet is upgraded.
obf.recordizer.policy = off, restart the profile/service and reconnect sessions. A running session does not change without reconnect.