Skip to content

Fix sub-band blocking after concentrator time jumps backwards - #8079

Open
halimi wants to merge 3 commits into
v3.37from
fix/scheduler-concentrator-time-jump
Open

halimi wants to merge 3 commits into
v3.37from
fix/scheduler-concentrator-time-jump

Conversation

@halimi

@halimi halimi commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

References: https://github.com/TheThingsIndustries/lorawan-stack-support/issues/1517

Downlink scheduling on LoRa Basics Station gateways could fail with sub band is blocked for up to hours after the concentrator time jumped backwards. This happens on gateways that do not extend the concentrator time beyond 32 bits (the time restarts from zero every 2^32 µs, ~71.6 minutes), and on gateways whose concentrator time base changes when they reconnect. The emissions recorded in the scheduler stayed in the old time base, so they appeared to be in the future and blocked their sub-band.

Changes

  • When the scheduler synchronizes with the gateway concentrator time and the new concentrator time lags more than one minute behind the time expected from the server time, it shifts all recorded emissions (scheduler-wide and per sub-band) to the new time base. The emissions keep counting towards the duty-cycle at their actual position, but no longer block the sub-band. A warning is logged when this happens.
  • Add a WithIgnorePriorUplinkTokens connection option, which makes the scheduler ignore uplink tokens of uplink messages received before the connection was established. The LoRa Basics Station frontend uses it, so that a downlink scheduled right after a reconnect cannot synchronize the new connection's clock to the previous connection's time base. Other frontends are unchanged, because their concentrator time does not depend on the connection.

Testing

Steps
  • go test ./pkg/gatewayserver/scheduling/... ./pkg/gatewayserver/io/...
  • TestScheduleConcentratorTimeJump covers a 32-bit rollover that the gateway did not account for, a time base reset, and normal jitter that must not trigger a shift.
  • TestScheduleSyncViaUplinkToken and TestIgnorePriorUplinkTokens cover ignoring uplink tokens from before the connection was established.
Results

All tests in pkg/gatewayserver/scheduling and pkg/gatewayserver/io/... pass. With the emission shift disabled, TestScheduleConcentratorTimeJump fails with sub band is blocked for 1h11m34.9s (rollover) and sub band is blocked for 94h0m1.95s (time base reset).

Regressions
  • Duty-cycle enforcement: emissions are shifted, not dropped, so they still count towards the duty-cycle after a jump.
  • Clock synchronization for frontends other than LoRa Basics Station is unchanged. The Semtech UDP frontend keeps using RolloverClock.Sync, which already treats a backwards timestamp as a rollover.
  • Small backwards jitter (LoRa Basics Station xtime can go back up to ~100 ms) stays below the one-minute tolerance and does not trigger a shift.

Notes for Reviewers

The shift happens during clock synchronization, before any scheduling, so the MaxScheduleAhead checks see the emissions in the new time base.

Checklist

  • Scope: The referenced issue is addressed, there are no unrelated changes.
  • Compatibility: The changes are backwards compatible with existing API, storage, configuration and CLI, according to the compatibility commitments in README.md for the chosen target branch.
  • Documentation: Relevant documentation is added or updated.
  • Testing: The steps/process to test this feature are clearly explained including testing for regressions.
  • Infrastructure: If infrastructural changes (e.g., new RPC, configuration) are needed, a separate issue is created in the infrastructural repositories.
  • Changelog: Significant features, behavior changes, deprecations and fixes are added to CHANGELOG.md.
  • Commits: Commit messages follow guidelines in CONTRIBUTING.md, there are no fixup commits left.

🤖 Generated with Claude Code

halimi and others added 2 commits October 29, 2026 18:37
Some LoRa Basics Station gateways do not extend the concentrator time
beyond 32 bits, so their concentrator time restarts from zero every
2^32 microseconds. The concentrator time base can also change, for
example after the clock was synchronized from an uplink token of a
previous gateway connection. In both cases, the recorded emissions
remained relative to the old time base and appeared to be in the
future, blocking their sub-band for up to hours.

Shift the emissions to the new time base when the synchronized
concentrator time lags more than a minute behind the concentrator time
expected from the server time. The emissions keep counting towards the
duty-cycle at their actual position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LoRa Basics Station gateways may start a new concentrator time session
when they reconnect. A downlink scheduled right after a reconnect could
carry an uplink token of an uplink message received on the previous
connection, and synchronize the clock of the new connection to the old
concentrator time base.

Add a connection option that makes the scheduler ignore uplink tokens
of uplink messages received before the connection was established, and
use it in the LoRa Basics Station frontend. Other frontends keep using
such uplink tokens, as their concentrator time does not depend on the
connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@halimi
halimi requested a review from a team as a code owner October 29, 2026 17:56
@halimi halimi added the c/gateway server This is related to the Gateway Server label Oct 29, 2026
@halimi
halimi requested a review from vlasebian October 29, 2026 17:56
@halimi halimi self-assigned this Oct 29, 2026
@halimi
halimi requested a balanced review from Copilot October 29, 2026 18:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical race in scheduler garbage collection allows synchronization to shift emissions after the cutoff is calculated, potentially deleting recent transmissions and future reservations and losing duty-cycle and collision accounting.

1 open issue
What changed in this PR

This PR addresses prolonged downlink blocking when a LoRa Basics Station gateway’s concentrator clock jumps backwards, while preserving duty-cycle accounting.

Changes:

  • Shift recorded emissions into the new time base when the clock falls more than one minute behind expectations.
  • Ignore pre-connection uplink tokens when synchronizing LoRa Basics Station gateway clocks.
  • Add regression tests and document the fixes in the changelog.
File Description
pkg/​gatewayserver/​scheduling/​sub_band.go Add synchronized shifting of sub-band emissions.
pkg/​gatewayserver/​scheduling/​scheduler.go Detect backward clock jumps, shift emissions, and filter stale uplink tokens.
pkg/​gatewayserver/​scheduling/​scheduler_test.go Cover clock resets, rollover, jitter, and stale tokens.
pkg/​gatewayserver/​scheduling/​emission.go Add an operation to shift emission timestamps.
pkg/​gatewayserver/​io/​semtechws/​ws.go Enable stale-token filtering for LoRa Basics Station connections.
pkg/​gatewayserver/​io/​io.go Forward the connection option to the scheduler.
pkg/​gatewayserver/​io/​io_test.go Test connection-level stale-token filtering.
CHANGELOG.md Document clock-jump recovery and stale-token handling.

Review effort: Balanced

The garbage collection determined the cutoff time under a read lock and
released it before removing the emissions. A clock synchronization that
shifts the emissions to a new time base could run in between, after
which the emissions were removed using the cutoff time of the old time
base. After a concentrator time jump, this could remove all emissions,
including the ones that still count towards the duty-cycle and the ones
scheduled in the future.

Hold the lock while determining the cutoff time and removing the
emissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the changes addressing downlink blocking after backwards clock jumps.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

@halimi
halimi requested a review from johanstokking October 29, 2026 18:37
@johanstokking

Copy link
Copy Markdown
Member

Thanks. I reviewed the code and it looks fine. I don't understand the cause.

This happens on gateways that do not extend the concentrator time beyond 32 bits (the time restarts from zero every 2^32 µs, ~71.6 minutes)

They all rollover right? In case of Basic Station, we have two fields in xtime: the session ID and the 32-bit concentrator timestamp:

// TimestampFromXTime constructs the concentrator timestamp associated with the
// provided XTime.
func TimestampFromXTime(xTime int64) uint32 {
// The concentrator timestamp is the 32 LSB.
return uint32(xTime & 0xFFFFFFFF)
}
// SessionIDFromXTime constructs the session ID associated with the provided XTime.
func SessionIDFromXTime(xTime int64) int32 {
return int32(xTime >> 48)
}

Since you cannot reproduce the issue, I think what we need to collect is better data: we need full logging on the LBS LNS, including xtime and downlink scheduling.

[This happens] on gateways whose concentrator time base changes when they reconnect

When they reconnect, we create a new scheduler and start all over with empty emissions right?

@halimi

halimi commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I reviewed the code and it looks fine. I don't understand the cause.

This happens on gateways that do not extend the concentrator time beyond 32 bits (the time restarts from zero every 2^32 µs, ~71.6 minutes)

They all rollover right? In case of Basic Station, we have two fields in xtime: the session ID and the 32-bit concentrator timestamp:

// TimestampFromXTime constructs the concentrator timestamp associated with the
// provided XTime.
func TimestampFromXTime(xTime int64) uint32 {
// The concentrator timestamp is the 32 LSB.
return uint32(xTime & 0xFFFFFFFF)
}
// SessionIDFromXTime constructs the session ID associated with the provided XTime.
func SessionIDFromXTime(xTime int64) int32 {
return int32(xTime >> 48)
}

Since you cannot reproduce the issue, I think what we need to collect is better data: we need full logging on the LBS LNS, including xtime and downlink scheduling.

Agreed, we need more data to better understand the issue. Especially if it only happens on one kind of gateway.

[This happens] on gateways whose concentrator time base changes when they reconnect

When they reconnect, we create a new scheduler and start all over with empty emissions right?

Correct, a reconnect creates a fresh scheduler. But what could happen is that a fresh scheduler has no clock sync, so a downlink right after the reconnect whose uplink token comes from the previous connection syncs the new clock to the old time base, and the emission is recorded there. The first uplink then moves the clock back, and if the difference was big between the two timestamps then that emission blocks the sub-band.

@johanstokking

Copy link
Copy Markdown
Member

The first uplink then moves the clock back, and if the difference was big between the two timestamps then that emission blocks the sub-band.

Yes, that is a good point and that is a good fix here. This is basically relevant for all frontends, not just LBS LNS.

Maybe that is the only culprit here. And it's an understandable one, i.e. when using Class C downlink with an uplink token that was from before a restart + reconnect.

Can you carve that out in a new PR, or reduce the scope of this PR? And also add debug logging so we can collect the right information.

@halimi

halimi commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Can you carve that out in a new PR, or reduce the scope of this PR? And also add debug logging so we can collect the right information.

Yes, I opened a new PR for it #8080

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c/gateway server This is related to the Gateway Server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants