test(gateway): qualify production path artifacts

This commit is contained in:
sechmachine
2026-07-30 11:15:55 +07:00
parent baf4073f68
commit 0c4d87406e
17 changed files with 659 additions and 139 deletions
+262 -85
View File
@@ -25,12 +25,12 @@ import (
"regexp"
"runtime"
"runtime/debug"
runtimemetrics "runtime/metrics"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -38,7 +38,7 @@ import (
)
const (
qualificationToolVersion = "versevdi-gateway-qualification/v3"
qualificationToolVersion = "versevdi-gateway-qualification/v4"
qualificationImpairmentQueuePackets = 64
qualificationImpairmentMaxPackets = 100_000
qualificationImpairmentPacketCount = 10_000
@@ -116,6 +116,7 @@ type qualificationProcessingSummary struct {
RawSamplesBytes int64 `json:"raw_samples_bytes"`
ResourceSamples int `json:"resource_samples"`
CPUSeconds float64 `json:"cpu_seconds"`
CPUScope string `json:"cpu_scope"`
PeakHeapBytes uint64 `json:"peak_heap_bytes"`
PeakGoroutines int `json:"peak_goroutines"`
Mallocs uint64 `json:"mallocs"`
@@ -132,9 +133,12 @@ type qualificationImpairmentObservation struct {
Sent int `json:"sent"`
Delivered int `json:"delivered"`
Dropped int `json:"dropped"`
InjectedDropped int `json:"injected_dropped"`
InjectedReordered int `json:"injected_reordered"`
ObservedOutOfOrder int `json:"observed_out_of_order"`
ObservedLatency time.Duration `json:"observed_one_way_latency_ns"`
ObservedRTT time.Duration `json:"observed_rtt_ns"`
RTTSource string `json:"rtt_source"`
ObservedJitter time.Duration `json:"observed_jitter_ns"`
ObservedLossPercent float64 `json:"observed_loss_percent"`
ObservedReorderPercent float64 `json:"observed_reorder_percent"`
@@ -304,6 +308,11 @@ type qualificationApolloFixture struct {
closeOnce sync.Once
sentPackets atomic.Uint64
work protocol.ProviderSessionWork
controlImpairmentMu sync.Mutex
controlRTT time.Duration
controlJitter time.Duration
controlRandom uint64
}
func newQualificationApolloFixture(t *testing.T, serverTLS, clientTLS *tls.Config, sessionID string, profile qualificationMediaProfile) *qualificationApolloFixture {
@@ -551,10 +560,7 @@ func (f *qualificationApolloFixture) serveControl() {
switch command {
case 1:
case apolloENetSendReliable, apolloENetPing, apolloENetDisconnect:
if _, err = f.control.WriteToUDP(sourceShapedENetAcknowledgePacket(7, 2, channel, sequence), remote); err != nil {
f.fail(err)
return
}
f.sendControlAcknowledge(sourceShapedENetAcknowledgePacket(7, 2, channel, sequence), remote)
if command == apolloENetDisconnect {
return
}
@@ -566,6 +572,45 @@ func (f *qualificationApolloFixture) serveControl() {
}
}
func (f *qualificationApolloFixture) setControlImpairment(profile qualificationImpairmentProfile) {
f.controlImpairmentMu.Lock()
f.controlRTT = profile.RTT
f.controlJitter = profile.Jitter
f.controlRandom = qualificationImpairmentSeed
f.controlImpairmentMu.Unlock()
}
func (f *qualificationApolloFixture) controlResponseDelay() time.Duration {
f.controlImpairmentMu.Lock()
defer f.controlImpairmentMu.Unlock()
delay := f.controlRTT
if f.controlJitter > 0 {
f.controlRandom ^= f.controlRandom << 13
f.controlRandom ^= f.controlRandom >> 7
f.controlRandom ^= f.controlRandom << 17
width := uint64(f.controlJitter*2 + 1)
delay += time.Duration(f.controlRandom%width) - f.controlJitter
}
return max(delay, 0)
}
func (f *qualificationApolloFixture) sendControlAcknowledge(packet []byte, remote *net.UDPAddr) {
delay := f.controlResponseDelay()
copyPacket := append([]byte(nil), packet...)
copyRemote := *remote
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
<-timer.C
if f.closed.Load() {
return
}
if _, err := f.control.WriteToUDP(copyPacket, &copyRemote); err != nil {
f.fail(err)
}
}()
}
func (f *qualificationApolloFixture) serveMedia(socket *net.UDPConn, video bool) {
buffer := make([]byte, apolloMediaMaximumPacket)
for {
@@ -751,9 +796,20 @@ func (f *qualificationFleet) Close() {
}
func newQualificationPath(t *testing.T, profile qualificationMediaProfile, pacerKbps int64) *qualificationPath {
return newQualificationPathWithImpairment(t, profile, pacerKbps, nil)
}
func newQualificationImpairedPath(t *testing.T, profile qualificationMediaProfile, pacerKbps int64, impairment qualificationImpairmentProfile) *qualificationPath {
return newQualificationPathWithImpairment(t, profile, pacerKbps, &impairment)
}
func newQualificationPathWithImpairment(t *testing.T, profile qualificationMediaProfile, pacerKbps int64, impairment *qualificationImpairmentProfile) *qualificationPath {
t.Helper()
serverTLS, clientTLS := testTLS(t)
fixture := newQualificationApolloFixture(t, serverTLS, clientTLS, "qualification-session", profile)
if impairment != nil {
fixture.setControlImpairment(*impairment)
}
backend := &qualificationTracingBackend{native: NewNativeApolloBackend()}
provider := NewApolloAdapter(backend, ProviderIdentity{})
authority := protocol.SessionAuthority{
@@ -1049,6 +1105,23 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
if _, err := buffered.WriteString("source_sequence,sent_ns,delivered_ns,processing_ns,outcome,delivery_order,bytes,queue_packets\n"); err != nil {
return qualificationImpairmentObservation{}, err
}
type scheduledPacket struct {
index int
target time.Duration
}
type rawSample struct {
sent, delivered time.Duration
processing time.Duration
outcome string
deliveryOrder int
bytes int
queuePackets int
}
type receivedPacket struct {
index, deliveryOrder, queuePackets int
deliveredAt time.Time
processing time.Duration
}
state := qualificationImpairmentSeed
random := func() uint64 {
state ^= state << 13
@@ -1065,22 +1138,126 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
Sent: packetCount, ConfiguredRTT: profile.RTT, ConfiguredJitter: profile.Jitter,
ConfiguredLossPercent: profile.LossPercent, ConfiguredReorder: profile.Reorder,
ConfiguredCapacitySteps: append([]int(nil), profile.CapacitySteps...),
RTTSource: "apollo_enet_acknowledge",
}
path := newQualificationPath(t, media, media.BitrateKbps)
path := newQualificationImpairedPath(t, media, media.BitrateKbps, profile)
defer path.Close()
started := time.Now()
payload := qualificationPayload(media)
var deliveries []qualificationDeliverySample
var totalRTT, totalJitter, previousRTT time.Duration
previousDelivered := -1
deliveryOrder := 0
type pendingPacket struct {
index int
jitter time.Duration
rawSamples := make([]rawSample, packetCount)
jobs := make([]scheduledPacket, 0, packetCount)
jobByIndex := make([]int, packetCount)
for index := range jobByIndex {
jobByIndex[index] = -1
}
pending := pendingPacket{index: -1}
for index := 0; index < packetCount; index++ {
jitter := time.Duration(0)
if profile.Jitter > 0 {
width := uint64(profile.Jitter*2 + 1)
jitter = time.Duration(random()%width) - profile.Jitter
}
rawSamples[index].sent = time.Duration(index) * spacing
if float64(random()%10_000) < profile.LossPercent*100 {
rawSamples[index].outcome = "injected_dropped"
observation.InjectedDropped++
continue
}
delay := max(profile.RTT/2+jitter, 0)
jobByIndex[index] = len(jobs)
jobs = append(jobs, scheduledPacket{index: index, target: time.Duration(index)*spacing + delay})
rawSamples[index].outcome = "traversal_dropped"
}
if profile.Reorder {
for index := 18; index+1 < packetCount; index += 20 {
first, second := jobByIndex[index], jobByIndex[index+1]
if first < 0 || second < 0 {
continue
}
earlier := min(jobs[first].target, jobs[second].target)
later := max(jobs[first].target, jobs[second].target)
jobs[second].target = earlier
jobs[first].target = later + time.Nanosecond
observation.InjectedReordered++
}
}
sort.SliceStable(jobs, func(first, second int) bool {
if jobs[first].target == jobs[second].target {
return jobs[first].index < jobs[second].index
}
return jobs[first].target < jobs[second].target
})
started := time.Now()
beforeMetrics := path.server.Metrics()
beforeIngress := path.session.mediaIngress.Load()
beforeRecovered := path.session.mediaRecovered.Load()
beforeEnqueued := path.session.mediaEnqueued.Load()
beforePacer := path.server.pacer.reservations.Load()
grace := max(2*profile.RTT+2*profile.Jitter, 2*time.Second)
lastTarget := time.Duration(packetCount) * spacing
if len(jobs) > 0 {
lastTarget = jobs[len(jobs)-1].target
}
receiveCtx, receiveCancel := context.WithDeadline(context.Background(), started.Add(lastTarget+grace))
defer receiveCancel()
receivedDone := make(chan struct {
packets []receivedPacket
err error
}, 1)
go func() {
result := struct {
packets []receivedPacket
err error
}{packets: make([]receivedPacket, 0, len(jobs))}
metrics := beforeMetrics
seen := make([]bool, packetCount)
for len(result.packets) < len(jobs) {
recovered, receiveErr := path.receivePayload(receiveCtx)
if receiveErr != nil {
if errors.Is(receiveErr, context.DeadlineExceeded) || errors.Is(receiveErr, context.Canceled) {
break
}
var timeout net.Error
if errors.As(receiveErr, &timeout) && timeout.Timeout() {
break
}
result.err = receiveErr
break
}
if len(recovered) != len(payload) {
result.err = errors.New("impaired payload length changed")
break
}
index := int(binary.BigEndian.Uint32(recovered[len(recovered)-4:]))
if index < 0 || index >= packetCount || jobByIndex[index] < 0 || seen[index] {
result.err = errors.New("impaired payload sequence invalid")
break
}
expected := append([]byte(nil), payload...)
binary.BigEndian.PutUint32(expected[len(expected)-4:], uint32(index))
if !bytes.Equal(recovered, expected) {
result.err = errors.New("impaired payload bytes changed")
break
}
seen[index] = true
current := path.server.Metrics()
processing := time.Duration(0)
if samples := current.ProcessingSamples - metrics.ProcessingSamples; samples > 0 {
processing = time.Duration((current.ProcessingDelayNanos - metrics.ProcessingDelayNanos) / samples)
}
metrics = current
result.packets = append(result.packets, receivedPacket{
index: index, deliveryOrder: len(result.packets) + 1,
deliveredAt: time.Now(), processing: processing, queuePackets: len(path.session.video),
})
}
receivedDone <- result
}()
stepAt := make(map[int]time.Time, len(profile.CapacitySteps))
deliver := func(packet pendingPacket) error {
for _, packet := range jobs {
if delay := time.Until(started.Add(packet.target)); delay > 0 {
time.Sleep(delay)
}
if len(profile.CapacitySteps) == 2 {
switch {
case packet.index >= packetCount*2/3 && stepAt[profile.CapacitySteps[1]].IsZero():
@@ -1091,79 +1268,67 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
stepAt[profile.CapacitySteps[0]] = time.Now()
}
}
sentAt := started.Add(time.Duration(packet.index) * spacing)
target := sentAt.Add(profile.RTT/2 + packet.jitter)
if delay := time.Until(target); delay > 0 {
time.Sleep(delay)
}
current := append([]byte(nil), payload...)
binary.BigEndian.PutUint32(current[len(current)-4:], uint32(packet.index))
trace, processing, err := path.traverse(t, current)
if err != nil || !trace.PayloadPreserved || !trace.ProductionPacer {
if err == nil {
err = errors.New("impaired packet bypassed production gateway path")
}
return err
if _, err := path.emit(t, current); err != nil {
receiveCancel()
return qualificationImpairmentObservation{}, err
}
deliveredAt := time.Now()
rtt := 2 * deliveredAt.Sub(sentAt)
totalRTT += rtt
if previousRTT != 0 {
delta := rtt - previousRTT
}
received := <-receivedDone
receiveCancel()
if received.err != nil {
return qualificationImpairmentObservation{}, received.err
}
var deliveries []qualificationDeliverySample
var totalLatency, totalJitter, previousLatency time.Duration
previousDelivered := -1
for _, packet := range received.packets {
sample := &rawSamples[packet.index]
sample.delivered = packet.deliveredAt.Sub(started)
sample.processing = packet.processing
sample.outcome = "delivered"
sample.deliveryOrder = packet.deliveryOrder
sample.bytes = media.PacketBytes
sample.queuePackets = packet.queuePackets
latency := sample.delivered - sample.sent
totalLatency += latency
if previousLatency != 0 {
delta := latency - previousLatency
if delta < 0 {
delta = -delta
}
totalJitter += delta
}
previousRTT = rtt
previousLatency = latency
if previousDelivered >= 0 && packet.index < previousDelivered {
observation.ObservedOutOfOrder++
}
previousDelivered = packet.index
observation.Delivered++
deliveryOrder++
wireBytes := int64(len(current) + frameHeaderSize)
deliveries = append(deliveries, qualificationDeliverySample{At: deliveredAt, Bytes: wireBytes})
queuePackets := int(path.session.mediaQueueMaximum.Load())
if _, err := fmt.Fprintf(buffered, "%d,%d,%d,%d,delivered,%d,%d,%d\n", packet.index,
sentAt.Sub(started).Nanoseconds(), deliveredAt.Sub(started).Nanoseconds(),
processing.Nanoseconds(), deliveryOrder, len(current), queuePackets); err != nil {
return err
}
observation.MaxQueuePackets = max(observation.MaxQueuePackets, queuePackets)
return nil
deliveries = append(deliveries, qualificationDeliverySample{At: packet.deliveredAt, Bytes: int64(media.PacketBytes + frameHeaderSize)})
observation.MaxQueuePackets = max(observation.MaxQueuePackets, packet.queuePackets)
}
for index := 0; index < packetCount; index++ {
jitter := time.Duration(0)
if profile.Jitter > 0 {
width := uint64(profile.Jitter*2 + 1)
jitter = time.Duration(random()%width) - profile.Jitter
}
if float64(random()%10_000) < profile.LossPercent*100 {
observation.Dropped++
if _, err := fmt.Fprintf(buffered, "%d,%d,0,0,dropped,0,0,%d\n", index, time.Duration(index)*spacing, path.session.mediaQueueMaximum.Load()); err != nil {
return qualificationImpairmentObservation{}, err
}
continue
}
packet := pendingPacket{index: index, jitter: jitter}
if profile.Reorder && index%20 == 18 {
pending = packet
continue
}
if err := deliver(packet); err != nil {
return qualificationImpairmentObservation{}, err
}
if pending.index >= 0 {
if err := deliver(pending); err != nil {
return qualificationImpairmentObservation{}, err
}
pending.index = -1
observation.InjectedReordered++
}
observation.Delivered = len(received.packets)
observation.Dropped = packetCount - observation.Delivered
completedAt := started
if observation.Delivered > 0 {
completedAt = received.packets[len(received.packets)-1].deliveredAt
}
if pending.index >= 0 {
if err := deliver(pending); err != nil {
afterMetrics := path.server.Metrics()
if observation.Delivered > 0 && (path.session.mediaIngress.Load() <= beforeIngress ||
path.session.mediaRecovered.Load() <= beforeRecovered ||
path.session.mediaEnqueued.Load() <= beforeEnqueued ||
afterMetrics.ProcessingSamples <= beforeMetrics.ProcessingSamples ||
path.server.pacer.reservations.Load() <= beforePacer ||
afterMetrics.MediaPackets <= beforeMetrics.MediaPackets) {
return qualificationImpairmentObservation{}, errors.New("impaired traffic bypassed a production gateway stage")
}
observation.ObservedRTT = path.session.Telemetry().ControlRTT
for index, sample := range rawSamples {
if _, err := fmt.Fprintf(buffered, "%d,%d,%d,%d,%s,%d,%d,%d\n", index,
sample.sent.Nanoseconds(), sample.delivered.Nanoseconds(), sample.processing.Nanoseconds(),
sample.outcome, sample.deliveryOrder, sample.bytes, sample.queuePackets); err != nil {
return qualificationImpairmentObservation{}, err
}
}
@@ -1178,28 +1343,32 @@ func runQualificationImpairment(t *testing.T, profile qualificationImpairmentPro
}
closed = true
if observation.Delivered > 0 {
observation.ObservedRTT = totalRTT / time.Duration(observation.Delivered)
observation.ObservedLatency = totalLatency / time.Duration(observation.Delivered)
if observation.Delivered > 1 {
observation.ObservedJitter = totalJitter / time.Duration(observation.Delivered-1)
}
observation.ObservedThroughputKbps = float64(observation.Delivered*media.PacketBytes*8) / time.Since(started).Seconds() / 1000
observation.ObservedThroughputKbps = float64(observation.Delivered*media.PacketBytes*8) / completedAt.Sub(started).Seconds() / 1000
}
observation.ObservedLossPercent = float64(observation.Dropped) * 100 / float64(packetCount)
observation.ObservedReorderPercent = float64(observation.ObservedOutOfOrder) * 100 / float64(packetCount)
if packetCount >= qualificationImpairmentPacketCount {
capacityFactor := 1.0
if len(profile.CapacitySteps) > 0 {
inverseRates := 1.0
rates := 1.0
for _, reduction := range profile.CapacitySteps {
inverseRates += 100 / float64(100-reduction)
rates += float64(100-reduction) / 100
}
capacityFactor = float64(len(profile.CapacitySteps)+1) / inverseRates
capacityFactor = rates / float64(len(profile.CapacitySteps)+1)
}
expectedThroughput := float64(media.BitrateKbps) * (1 - profile.LossPercent/100) * capacityFactor
lowerThroughput := expectedThroughput * 0.90
upperThroughput := expectedThroughput * 1.05
if observation.ObservedThroughputKbps < lowerThroughput || observation.ObservedThroughputKbps > upperThroughput {
return qualificationImpairmentObservation{}, fmt.Errorf("observed throughput %.2f outside [%.2f,%.2f]", observation.ObservedThroughputKbps, lowerThroughput, upperThroughput)
return qualificationImpairmentObservation{}, fmt.Errorf(
"observed throughput %.2f outside [%.2f,%.2f] over %s with %d delivered/%d injected drops and steps %v",
observation.ObservedThroughputKbps, lowerThroughput, upperThroughput,
completedAt.Sub(started), observation.Delivered, observation.InjectedDropped, stepAt,
)
}
}
observation.RawSamples = filepath.Base(rawPath)
@@ -1401,7 +1570,11 @@ func runQualificationProcessing(t *testing.T, profile qualificationMediaProfile,
summary.RawResourcesSHA256 = resourceSum
summary.RawResourcesBytes = resourceSize
summary.ResourceSamples = len(resources)
summary.CPUScope = "isolated gateway qualification process (gateway plus bounded fixture/client driver)"
firstResource, lastResource := resources[0], resources[len(resources)-1]
if firstResource.CPUSeconds < 0 || lastResource.CPUSeconds < 0 {
return qualificationProcessingSummary{}, errors.New("process CPU usage unavailable")
}
summary.CPUSeconds = math.Max(0, lastResource.CPUSeconds-firstResource.CPUSeconds)
summary.Mallocs = lastResource.Mallocs - firstResource.Mallocs
summary.AllocatedBytes = lastResource.Allocated - firstResource.Allocated
@@ -1441,12 +1614,16 @@ func runQualificationWarmup(t *testing.T, path *qualificationPath, profile quali
}
func qualificationRuntimeSample(started time.Time) qualificationResourceSample {
cpu := []runtimemetrics.Sample{{Name: "/cpu/classes/total:cpu-seconds"}}
runtimemetrics.Read(cpu)
var usage syscall.Rusage
cpuSeconds := -1.0
if syscall.Getrusage(syscall.RUSAGE_SELF, &usage) == nil {
cpuSeconds = float64(usage.Utime.Sec+usage.Stime.Sec) +
float64(usage.Utime.Usec+usage.Stime.Usec)/1_000_000
}
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
return qualificationResourceSample{
Elapsed: time.Since(started), CPUSeconds: cpu[0].Value.Float64(),
Elapsed: time.Since(started), CPUSeconds: cpuSeconds,
HeapBytes: memory.HeapAlloc, Goroutines: runtime.NumGoroutine(),
Mallocs: memory.Mallocs, Allocated: memory.TotalAlloc,
}