fix: recover correct bandwidth rate when 32-bit SNMP counters wrap (#161)

Standard ifInOctets/ifOutOctets are 32-bit counters that wrap around
4,294,967,296 bytes (~4.3 GB). At 1 Gbps this wraps every ~34 seconds,
causing brief drops to 0 bps in traffic charts and capacity reports.

When prev > 2^31 and delta is negative, add 2^32 to recover the correct
rate. If the result is still negative (HC 64-bit counter after reboot),
clamp to 0. Small-value decreases (prev < 2^31) continue to clamp to 0
since they cannot be real 32-bit wraps.

Fixes both capacity.ex (utilization reports) and device_live/show.ex
(per-device traffic chart).

Reviewed-on: graham/towerops-web#161
This commit is contained in:
Graham McIntire 2026-03-25 09:52:15 -05:00 committed by graham
parent 53c8f495d1
commit f0fe3c025c
3 changed files with 81 additions and 6 deletions

View file

@ -166,7 +166,18 @@ defmodule Towerops.Capacity do
defp calculate_bps(prev_octets, curr_octets, time_diff) do
delta = curr_octets - prev_octets
# Clamp negative deltas to 0 (counter wrap)
# When prev is in the upper half of 32-bit range and delta is negative,
# the 32-bit counter likely wrapped around 2^32. Add 2^32 to recover
# the correct delta. If it's still negative after the correction, it's
# a device reboot/reset on 64-bit HC counters — clamp to 0.
delta =
if delta < 0 and prev_octets > 2_147_483_648 do
delta + 4_294_967_296
else
delta
end
max(0.0, delta * 8 / time_diff)
end

View file

@ -1242,14 +1242,14 @@ defmodule ToweropsWeb.DeviceLive.Show do
in_bps =
if s2.if_in_octets && s1.if_in_octets do
max((s2.if_in_octets - s1.if_in_octets) * 8 / time_diff, 0)
calculate_counter_bps(s1.if_in_octets, s2.if_in_octets, time_diff)
else
0.0
end
out_bps =
if s2.if_out_octets && s1.if_out_octets do
max((s2.if_out_octets - s1.if_out_octets) * 8 / time_diff, 0)
calculate_counter_bps(s1.if_out_octets, s2.if_out_octets, time_diff)
else
0.0
end
@ -1262,6 +1262,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
end)
end
defp calculate_counter_bps(prev_octets, curr_octets, time_diff) do
delta = curr_octets - prev_octets
delta =
if delta < 0 and prev_octets > 2_147_483_648 do
delta + 4_294_967_296
else
delta
end
max(0.0, delta * 8 / time_diff)
end
defp build_traffic_json_datasets(all_stats, device) do
in_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: Float.round(s.in_bps, 2)} end)
out_data = Enum.map(all_stats, fn s -> %{x: s.timestamp_ms, y: -Float.round(s.out_bps, 2)} end)

View file

@ -83,7 +83,7 @@ defmodule Towerops.CapacityTest do
assert_in_delta result.max_bps, 133_333.33, 1.0
end
test "clamps negative deltas to zero (counter wrap)", %{interface: interface} do
test "clamps to zero for small counter decreases (device reset or glitch)", %{interface: interface} do
now = DateTime.utc_now()
t1 = DateTime.add(now, -120, :second)
t2 = DateTime.add(now, -60, :second)
@ -94,7 +94,7 @@ defmodule Towerops.CapacityTest do
checked_at: t1
})
# Counter wrapped - lower value than previous
# Small decrease - treated as reset/glitch, not a 32-bit wrap
insert_interface_stat(interface.id, %{
if_in_octets: 1_000_000,
if_out_octets: 6_000_000,
@ -103,11 +103,62 @@ defmodule Towerops.CapacityTest do
result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second))
# Negative delta clamped to 0
assert result.in_bps == 0.0
assert_in_delta result.out_bps, 133_333.33, 1.0
end
test "recovers correct rate when 32-bit counter wraps", %{interface: interface} do
now = DateTime.utc_now()
t1 = DateTime.add(now, -120, :second)
t2 = DateTime.add(now, -60, :second)
# Counter was near the 32-bit maximum (2^32 = 4,294,967,296)
insert_interface_stat(interface.id, %{
if_in_octets: 4_290_000_000,
if_out_octets: 4_290_000_000,
checked_at: t1
})
# Counter wrapped around — now at a small value
insert_interface_stat(interface.id, %{
if_in_octets: 100_000_000,
if_out_octets: 100_000_000,
checked_at: t2
})
result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second))
# Correct delta: (4,294,967,296 - 4,290,000,000) + 100,000,000 = 104,967,296 bytes in 60s
# bps = 104,967,296 * 8 / 60 = 13,995,639 bps
assert_in_delta result.in_bps, 13_995_639.0, 1.0
assert_in_delta result.out_bps, 13_995_639.0, 1.0
end
test "clamps to zero when HC 64-bit counter resets after device reboot", %{interface: interface} do
now = DateTime.utc_now()
t1 = DateTime.add(now, -120, :second)
t2 = DateTime.add(now, -60, :second)
# Large 64-bit HC counter value (device had transferred 10 TB)
insert_interface_stat(interface.id, %{
if_in_octets: 10_000_000_000_000,
if_out_octets: 10_000_000_000_000,
checked_at: t1
})
# Device rebooted — counter reset to small value
insert_interface_stat(interface.id, %{
if_in_octets: 1_000_000,
if_out_octets: 1_000_000,
checked_at: t2
})
result = Capacity.calculate_throughput(interface.id, DateTime.add(now, -180, :second))
assert result.in_bps == 0.0
assert result.out_bps == 0.0
end
test "returns zero throughput when no stats exist", %{interface: interface} do
result = Capacity.calculate_throughput(interface.id, DateTime.add(DateTime.utc_now(), -3600, :second))
assert result.in_bps == 0.0