Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ let package = Package(
products: [
.library(name: "Split", targets: ["Split"]),

.library(name: "SplitCommons", targets: ["Logging"]),],
.library(name: "SplitCommons", targets: ["Logging", "BackoffCounter"]),],
targets: [
.target(
name: "Split",
dependencies: ["Logging"],
dependencies: ["BackoffCounter", "Logging"],
path: "Split",
exclude: [
"Common/Yaml/LICENSE",
Expand All @@ -32,6 +32,17 @@ let package = Package(
dependencies: ["Logging"],
path: "Sources/Logging/Tests"
),

.target(
name: "BackoffCounter",
dependencies: ["Logging"],
exclude: ["Tests", "README.md"]
),
.testTarget(
name: "BackoffCounterTests",
dependencies: ["BackoffCounter"],
path: "Sources/BackoffCounter/Tests"
),
// #INJECT_TARGET
]
)
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
//
// ReconnectBackoffCounter.swift
// Split
//
// BackoffCounter
// Created by Javier L. Avrudsky on 13/08/2020.
// Copyright © 2020 Split. All rights reserved.
//

import Foundation

protocol ReconnectBackoffCounter {
public protocol BackoffCounter {

Check warning on line 7 in Sources/BackoffCounter/BackoffCounter.swift

View workflow job for this annotation

GitHub Actions / test (SplitiOSUnit_5, SplitTestsSwift5) / test

public protocol 'BackoffCounter.BackoffCounter' shadows module 'BackoffCounter', which may cause failures when importing 'BackoffCounter' or its clients in some configurations; please rename either the protocol 'BackoffCounter.BackoffCounter' or the module 'BackoffCounter', or see https://github.com/apple/swift/issues/56573 for workarounds

Check warning on line 7 in Sources/BackoffCounter/BackoffCounter.swift

View workflow job for this annotation

GitHub Actions / test (SplitiOSUnit_5, SplitTestsSwift5) / test

public protocol 'BackoffCounter.BackoffCounter' shadows module 'BackoffCounter', which may cause failures when importing 'BackoffCounter' or its clients in some configurations; please rename either the protocol 'BackoffCounter.BackoffCounter' or the module 'BackoffCounter', or see https://github.com/apple/swift/issues/56573 for workarounds

Check warning on line 7 in Sources/BackoffCounter/BackoffCounter.swift

View workflow job for this annotation

GitHub Actions / test (SplitiOSUnit_5, SplitTestsSwift5) / test

public protocol 'BackoffCounter.BackoffCounter' shadows module 'BackoffCounter', which may cause failures when importing 'BackoffCounter' or its clients in some configurations; please rename either the protocol 'BackoffCounter.BackoffCounter' or the module 'BackoffCounter', or see https://github.com/apple/swift/issues/56573 for workarounds

Check warning on line 7 in Sources/BackoffCounter/BackoffCounter.swift

View workflow job for this annotation

GitHub Actions / test (SplitiOSUnit_5, SplitTestsSwift5) / test

public protocol 'BackoffCounter.BackoffCounter' shadows module 'BackoffCounter', which may cause failures when importing 'BackoffCounter' or its clients in some configurations; please rename either the protocol 'BackoffCounter.BackoffCounter' or the module 'BackoffCounter', or see https://github.com/apple/swift/issues/56573 for workarounds
func getNextRetryTime() -> Double
func resetCounter()
}

class DefaultReconnectBackoffCounter: ReconnectBackoffCounter, @unchecked Sendable {
public class DefaultBackoffCounter: BackoffCounter, @unchecked Sendable {
private var maxTimeLimitInSecs: Double = 1800.0 // 30 minutes (30 * 60)
private static let kRetryExponentialBase = 2
private let backoffBase: Int
private var attemptCount: AtomicInt
private var attemptCount: Int = 0
private let lock = NSLock()

init(backoffBase: Int, maxTimeLimit: Int? = nil) {
public init(backoffBase: Int, maxTimeLimit: Int? = nil) {
self.backoffBase = backoffBase
self.attemptCount = AtomicInt(0)
if let max = maxTimeLimit {
maxTimeLimitInSecs = Double(max)
}
}

func getNextRetryTime() -> Double {
public func getNextRetryTime() -> Double {
lock.lock()
let currentAttempt = attemptCount
attemptCount += 1
lock.unlock()

let base = Decimal(backoffBase * Self.kRetryExponentialBase)
let decimalResult = pow(base, attemptCount.getAndAdd(1))
let decimalResult = pow(base, currentAttempt)

var retryTime = maxTimeLimitInSecs
if !decimalResult.isNaN, decimalResult < Decimal(maxTimeLimitInSecs) {
Expand All @@ -39,7 +39,9 @@
return retryTime
}

func resetCounter() {
attemptCount .mutate { $0 = 0 }
public func resetCounter() {
lock.lock()
attemptCount = 0
lock.unlock()
}
}
56 changes: 56 additions & 0 deletions Sources/BackoffCounter/BackoffCounterTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// BackoffCounterTimer
// Created by Javier L. Avrudsky on 20/10/2020.
// Copyright © 2020 Split. All rights reserved.

import Foundation
#if !COCOAPODS
import Logging
#endif

public protocol BackoffCounterTimer {
func schedule(handler: @escaping @Sendable () -> Void)
func cancel()
}

public class DefaultBackoffCounterTimer: BackoffCounterTimer, @unchecked Sendable {
private let backoffCounter: BackoffCounter
private let queue = DispatchQueue(label: "split-backoff-timer")
private let timersQueue = DispatchQueue.global(qos: .default)
private var workItem: DispatchWorkItem?
private var isScheduled: Bool = false
private let scheduleLock = NSLock()

public init(backoffCounter: BackoffCounter) {
self.backoffCounter = backoffCounter
}

public func schedule(handler: @escaping @Sendable () -> Void) {
queue.async {
self.schedule(handler)
}
}

public func cancel() {
queue.async {
self.workItem?.cancel()
self.workItem = nil
self.backoffCounter.resetCounter()
}
}

private func schedule(_ handler: @escaping () -> Void) {
scheduleLock.lock()
if workItem != nil, isScheduled { scheduleLock.unlock(); return }
isScheduled = true
scheduleLock.unlock()

let workItem = DispatchWorkItem(block: {
self.scheduleLock.lock(); self.isScheduled = false; self.scheduleLock.unlock()
handler()
})
let delayInSeconds = backoffCounter.getNextRetryTime()
Logger.d("Retrying reconnection in \(delayInSeconds) seconds")
timersQueue.asyncAfter(deadline: DispatchTime.now() + Double(delayInSeconds), execute: workItem)
self.workItem = workItem
}
}
75 changes: 75 additions & 0 deletions Sources/BackoffCounter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# BackoffCounter

A thread-safe exponential backoff implementation for retry logic.

## Overview

This module provides two main components:

- **BackoffCounter**: Calculates exponential backoff times for retry operations
- **BackoffCounterTimer**: Schedules operations with automatic backoff delays

## Usage

### Basic BackoffCounter

```swift
// Create a counter with base 1 (delays: 1s, 2s, 4s, 8s, 16s... up to 30 min)
let counter = DefaultBackoffCounter(backoffBase: 1)

// Get next retry time (exponentially increasing)
let delay1 = counter.getNextRetryTime() // 1.0
let delay2 = counter.getNextRetryTime() // 2.0
let delay3 = counter.getNextRetryTime() // 4.0

// Reset after successful operation
counter.resetCounter()
```

### Custom Configuration

```swift
// Higher base = faster growth (delays: 1s, 4s, 16s, 64s...)
let aggressiveCounter = DefaultBackoffCounter(backoffBase: 2)

// Custom max time limit (default is 1800 seconds / 30 minutes)
let limitedCounter = DefaultBackoffCounter(backoffBase: 1, maxTimeLimit: 60)
```

### BackoffCounterTimer

```swift
let counter = DefaultBackoffCounter(backoffBase: 1)
let timer = DefaultBackoffCounterTimer(backoffCounter: counter)

// Schedule a retry operation
timer.schedule {
// This will be called after the backoff delay
performRetryOperation()
}

// Cancel pending retry and reset counter
timer.cancel()
```

## Backoff Formula

The retry time is calculated as:

```
retryTime = (backoffBase * 2) ^ attemptCount
```

Where `attemptCount` starts at 0 and increments with each call to `getNextRetryTime()`.

### Example Sequences

| Base | Attempt 0 | Attempt 1 | Attempt 2 | Attempt 3 | Max (default) |
|------|-----------|-----------|-----------|-----------|---------------|
| 1 | 1s | 2s | 4s | 8s | 1800s |
| 2 | 1s | 4s | 16s | 64s | 1800s |
| 3 | 1s | 6s | 36s | 216s | 1800s |

## Thread Safety

Both `DefaultBackoffCounter` and `DefaultBackoffCounterTimer` are thread-safe and conform to `Sendable`.
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
//
// ReconnectBackoffCounterTest.swift
// SplitTests
//
// BackoffCounterTest
// Created by Javier L. Avrudsky on 13/08/2020.
// Copyright © 2020 Split. All rights reserved.
//

import Foundation

import XCTest
@testable import Split

class ReconnectBackoffCounterTest: XCTestCase {
override func setUp() {
}
class BackoffCounterTest: XCTestCase {

func testBase1() {
let results: [Double] = [1, 2, 4, 8, 30, 1]
Expand All @@ -36,7 +30,7 @@ class ReconnectBackoffCounterTest: XCTestCase {
}

private func testWithBase(base: Int, results: [Double]) {
let counter = DefaultReconnectBackoffCounter(backoffBase: base);
let counter = DefaultBackoffCounter(backoffBase: base);
let v1 = counter.getNextRetryTime()
let v2 = counter.getNextRetryTime()
let v3 = counter.getNextRetryTime()
Expand All @@ -56,8 +50,4 @@ class ReconnectBackoffCounterTest: XCTestCase {
XCTAssertEqual(1800, vMax)
XCTAssertEqual(1, vReset)
}

override func tearDown() {

}
}
Loading
Loading