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:
parent
53c8f495d1
commit
f0fe3c025c
3 changed files with 81 additions and 6 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue