Remove dependency on DispatchWallTime (fix #384) (#540)

Fix
[#384](https://github.com/swift-server/swift-aws-lambda-runtime/issues/384)

Note: this PR introduces an API change that will break Lambda functions
using `LambdaContext`, we should integrate this change during the beta
otherwise it will require a major version bump.

### Motivation:

`DispatchWallTime` has no public API to extract the time in
milliseconds, making it a dead end.
Previous implementation used the internal representation of time inside
`DispatchWallTime` to extract the value, creating a risk if its
implementation will change in the future.
Moreover, the use of `DispatchWallTime` obliges users to import the
`Dispatch` library or `Foundation`.

Old Code:
```
extension DispatchWallTime {
    @usableFromInline
    init(millisSinceEpoch: Int64) {
        let nanoSinceEpoch = UInt64(millisSinceEpoch) * 1_000_000
        let seconds = UInt64(nanoSinceEpoch / 1_000_000_000)
        let nanoseconds = nanoSinceEpoch - (seconds * 1_000_000_000)
        self.init(timespec: timespec(tv_sec: Int(seconds), tv_nsec: Int(nanoseconds)))
    }

    var millisSinceEpoch: Int64 {
        Int64(bitPattern: self.rawValue) / -1_000_000
    }
}
```

Issue
[#384](https://github.com/swift-server/swift-aws-lambda-runtime/issues/384)
has a long discussion about possible replacements, including creating a
brand new `UTCClock`, which I think is an overkill for this project.

Instead, I propose this simple implementation, based on two assumptions:

- AWS always sends the time in milliseconds since Unix Epoch (1st Jan
1970) ([Lambda Runtime API
documentation](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next))
- AWS always uses UTC time (not only for Lambda, this is a general rule
for all AWS APIs) ([TZ=UTC on
Lambda](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html))

Therefore, this library just needs to store and make math on
milliseconds since epoch, without having to care about timezone.

I had two possibilities to implement the storage and the math on
milliseconds since Unix Epoch: either I could use an `UInt64` (as does
[the Rust
implementation](https://github.com/awslabs/aws-lambda-rust-runtime/blob/aff8d883c62997ef2615714dce9f7ddfd557147d/lambda-runtime/src/types.rs#L70))
or I could use a standard Swift type, such as `Duration`.

`Duration` is a good candidate for this because 1/ the time we receive
from the Lambda Service API is indeed a duration between 1/1/1970 and
the execution deadline for the Lambda function, expressed in
milliseconds, 2/ it gives a strong type that can be verified by the
compiler, and 3/ it is possible to do basic arithmetic operations and
compare two values.

As an additional benefit, it allows library users to not import
`Dispatch` or `Foundation`

### Modifications:

I made two changes:

1. I extend the `Duration` type to provide us with simple unix epoch
time manipulation functions and values.

```swift
extension Duration {
    /// Returns the time in milliseconds since the Unix epoch.
    @usableFromInline
    static var millisSinceEpoch: Duration {
        var ts = timespec()
        clock_gettime(CLOCK_REALTIME, &ts)
        return .milliseconds(Int64(ts.tv_sec) * 1000 + Int64(ts.tv_nsec) / 1_000_000)
    }

    /// Returns a Duration between Unix epoch and the distant future
    @usableFromInline
    static var distantFuture: Duration {
        // Use a very large value to represent the distant future
        millisSinceEpoch + Duration.seconds(.greatestFiniteMagnitude)
    }

    /// Returns the Duration in milliseconds
    @usableFromInline
    func milliseconds() -> Int64 {
        Int64(self / .milliseconds(1))
    }

    /// Create a Duration from milliseconds since Unix Epoch
    @usableFromInline
    init(millisSinceEpoch: Int64) {
        self = .milliseconds(millisSinceEpoch)
    }
}
```

3. I replaced all references to `DispatchWallTime` by `Duration` 

### Result:

No more `DispatchWallTime`
No dependencies on Foundation, as I use `clock_gettime()` to get the
epoch from the system clock.
This commit is contained in:
Sébastien Stormacq
2025-08-05 09:12:11 +02:00
committed by GitHub
parent 11bea7b2ee
commit 447c1e4db1
8 changed files with 363 additions and 33 deletions
@@ -20,9 +20,12 @@ import struct Foundation.Date
#endif
extension LambdaContext {
/// Returns the deadline as a Date for the Lambda function execution.
/// I'm not sure how usefull it is to have this as a Date, with only seconds precision,
/// but I leave it here for compatibility with the FoundationJSONSupport trait.
var deadlineDate: Date {
let secondsSinceEpoch = Double(Int64(bitPattern: self.deadline.rawValue)) / -1_000_000_000
return Date(timeIntervalSince1970: secondsSinceEpoch)
// Date(timeIntervalSince1970:) expects seconds, so we convert milliseconds to seconds.
Date(timeIntervalSince1970: Double(self.deadline.millisecondsSinceEpoch()) / 1000)
}
}
#endif // trait: FoundationJSONSupport
@@ -650,7 +650,7 @@ internal struct LambdaHTTPServer {
"arn:aws:lambda:us-east-1:\(Int16.random(in: Int16.min ... Int16.max)):function:custom-runtime"
),
(AmazonHeaders.traceID, "Root=\(AmazonHeaders.generateXRayTraceID());Sampled=1"),
(AmazonHeaders.deadline, "\(DispatchWallTime.distantFuture.millisSinceEpoch)"),
(AmazonHeaders.deadline, "\(LambdaClock.maxLambdaDeadline)"),
])
return LocalServerResponse(
+2 -2
View File
@@ -70,8 +70,8 @@ public enum Lambda {
requestID: invocation.metadata.requestID,
traceID: invocation.metadata.traceID,
invokedFunctionARN: invocation.metadata.invokedFunctionARN,
deadline: DispatchWallTime(
millisSinceEpoch: invocation.metadata.deadlineInMillisSinceEpoch
deadline: LambdaClock.Instant(
millisecondsSinceEpoch: invocation.metadata.deadlineInMillisSinceEpoch
),
logger: logger
)
+184
View File
@@ -0,0 +1,184 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
#if os(macOS)
import Darwin.C
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#elseif os(Windows)
import ucrt
#else
#error("Unsupported platform")
#endif
/// A clock implementation based on Unix epoch time for AWS Lambda runtime operations.
///
/// `LambdaClock` provides millisecond-precision timing based on the Unix epoch
/// (January 1, 1970, 00:00:00 UTC). This clock is designed for Lambda runtime
/// operations where precise wall-clock time is required.
///
/// ## Usage
///
/// ```swift
/// let clock = LambdaClock()
/// let now = clock.now
/// let deadline = now.advanced(by: .seconds(30))
///
/// // Sleep until deadline
/// try await clock.sleep(until: deadline)
/// ```
///
/// ## Performance
///
/// This clock uses `clock_gettime(CLOCK_REALTIME)` on Unix systems for
/// high-precision wall-clock time measurement with millisecond resolution.
///
/// ## TimeZone Handling
///
/// The Lambda execution environment uses UTC as a timezone,
/// `LambdaClock` operates in UTC and does not account for time zones.
/// see: TZ in https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html
public struct LambdaClock: Clock {
public typealias Duration = Swift.Duration
/// A moment in time represented as milliseconds since the Unix epoch.
///
/// `Instant` represents a specific point in time as the number of milliseconds
/// that have elapsed since January 1, 1970, 00:00:00 UTC (Unix epoch).
///
/// ## Thread Safety
///
/// `Instant` is a value type and is inherently thread-safe.
public struct Instant: InstantProtocol {
/// The number of milliseconds since the Unix epoch.
let instant: Int64
public typealias Duration = Swift.Duration
/// Creates a new instant by adding a duration to this instant.
///
/// - Parameter duration: The duration to add to this instant.
/// - Returns: A new instant advanced by the specified duration.
///
/// ## Example
///
/// ```swift
/// let now = LambdaClock().now
/// let future = now.advanced(by: .seconds(30))
/// ```
public func advanced(by duration: Duration) -> Instant {
.init(millisecondsSinceEpoch: Int64(instant + Int64(duration / .milliseconds(1))))
}
/// Calculates the duration between this instant and another instant.
///
/// - Parameter other: The target instant to calculate duration to.
/// - Returns: The duration from this instant to the other instant.
/// Positive if `other` is in the future, negative if in the past.
///
/// ## Example
///
/// ```swift
/// let start = LambdaClock().now
/// // ... some work ...
/// let end = LambdaClock().now
/// let elapsed = start.duration(to: end)
/// ```
public func duration(to other: Instant) -> Duration {
.milliseconds(other.instant - self.instant)
}
/// Compares two instants for ordering.
///
/// - Parameters:
/// - lhs: The left-hand side instant.
/// - rhs: The right-hand side instant.
/// - Returns: `true` if `lhs` represents an earlier time than `rhs`.
public static func < (lhs: Instant, rhs: Instant) -> Bool {
lhs.instant < rhs.instant
}
/// Returns this instant as the number of milliseconds since the Unix epoch.
/// - Returns: The number of milliseconds since the Unix epoch.
public func millisecondsSinceEpoch() -> Int64 {
self.instant
}
/// Creates an instant from milliseconds since the Unix epoch.
/// - Parameter milliseconds: The number of milliseconds since the Unix epoch.
public init(millisecondsSinceEpoch milliseconds: Int64) {
self.instant = milliseconds
}
}
/// The current instant according to this clock.
///
/// This property returns the current wall-clock time as milliseconds
/// since the Unix epoch.
/// This method uses `clock_gettime(CLOCK_REALTIME)` to obtain high-precision
/// wall-clock time.
///
/// - Returns: An `Instant` representing the current time.
public var now: Instant {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
return .init(millisecondsSinceEpoch: Int64(ts.tv_sec) * 1000 + Int64(ts.tv_nsec) / 1_000_000)
}
/// The minimum resolution of this clock.
///
/// `LambdaClock` provides millisecond resolution.
public var minimumResolution: Duration {
.milliseconds(1)
}
/// Suspends the current task until the specified deadline.
///
/// - Parameters:
/// - deadline: The instant until which to sleep.
/// - tolerance: The allowed tolerance for the sleep duration. Currently unused.
///
/// - Throws: `CancellationError` if the task is cancelled during sleep.
///
/// ## Example
///
/// ```swift
/// let clock = LambdaClock()
/// let deadline = clock.now.advanced(by: .seconds(5))
/// try await clock.sleep(until: deadline)
/// ```
public func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws {
let now = self.now
let sleepDuration = now.duration(to: deadline)
if sleepDuration > .zero {
try await ContinuousClock().sleep(for: sleepDuration)
}
}
/// Hardcoded maximum execution time for a Lambda function.
public static var maxLambdaExecutionTime: Duration {
// 15 minutes in milliseconds
// see https://docs.aws.amazon.com/lambda/latest/dg/configuration-timeout.html
.milliseconds(15 * 60 * 1000)
}
/// Returns the maximum deadline for a Lambda function execution.
/// This is the current time plus the maximum execution time.
/// This function is only used by the local server for testing purposes.
public static var maxLambdaDeadline: Instant {
LambdaClock().now.advanced(by: maxLambdaExecutionTime)
}
}
+9 -12
View File
@@ -12,7 +12,6 @@
//
//===----------------------------------------------------------------------===//
import Dispatch
import Logging
import NIOCore
@@ -89,7 +88,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
let requestID: String
let traceID: String
let invokedFunctionARN: String
let deadline: DispatchWallTime
let deadline: LambdaClock.Instant
let cognitoIdentity: String?
let clientContext: ClientContext?
let logger: Logger
@@ -98,7 +97,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
requestID: String,
traceID: String,
invokedFunctionARN: String,
deadline: DispatchWallTime,
deadline: LambdaClock.Instant,
cognitoIdentity: String?,
clientContext: ClientContext?,
logger: Logger
@@ -131,7 +130,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
}
/// The timestamp that the function times out.
public var deadline: DispatchWallTime {
public var deadline: LambdaClock.Instant {
self.storage.deadline
}
@@ -156,7 +155,7 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
requestID: String,
traceID: String,
invokedFunctionARN: String,
deadline: DispatchWallTime,
deadline: LambdaClock.Instant,
cognitoIdentity: String? = nil,
clientContext: ClientContext? = nil,
logger: Logger
@@ -173,11 +172,8 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
}
public func getRemainingTime() -> Duration {
let deadline = self.deadline.millisSinceEpoch
let now = DispatchWallTime.now().millisSinceEpoch
let remaining = deadline - now
return .milliseconds(remaining)
let deadline = self.deadline
return LambdaClock().now.duration(to: deadline)
}
public var debugDescription: String {
@@ -185,18 +181,19 @@ public struct LambdaContext: CustomDebugStringConvertible, Sendable {
}
/// This interface is not part of the public API and must not be used by adopters. This API is not part of semver versioning.
/// The timeout is expressed relative to now
package static func __forTestsOnly(
requestID: String,
traceID: String,
invokedFunctionARN: String,
timeout: DispatchTimeInterval,
timeout: Duration,
logger: Logger
) -> LambdaContext {
LambdaContext(
requestID: requestID,
traceID: traceID,
invokedFunctionARN: invokedFunctionARN,
deadline: .now() + timeout,
deadline: LambdaClock().now.advanced(by: timeout),
logger: logger
)
}
+1 -16
View File
@@ -12,7 +12,6 @@
//
//===----------------------------------------------------------------------===//
import Dispatch
import NIOConcurrencyHelpers
import NIOPosix
@@ -39,20 +38,6 @@ enum AmazonHeaders {
static let invokedFunctionARN = "Lambda-Runtime-Invoked-Function-Arn"
}
extension DispatchWallTime {
@usableFromInline
init(millisSinceEpoch: Int64) {
let nanoSinceEpoch = UInt64(millisSinceEpoch) * 1_000_000
let seconds = UInt64(nanoSinceEpoch / 1_000_000_000)
let nanoseconds = nanoSinceEpoch - (seconds * 1_000_000_000)
self.init(timespec: timespec(tv_sec: Int(seconds), tv_nsec: Int(nanoseconds)))
}
var millisSinceEpoch: Int64 {
Int64(bitPattern: self.rawValue) / -1_000_000
}
}
extension String {
func encodeAsJSONString(into bytes: inout [UInt8]) {
bytes.append(UInt8(ascii: "\""))
@@ -103,7 +88,7 @@ extension AmazonHeaders {
// The version number, that is, 1.
let version: UInt = 1
// The time of the original request, in Unix epoch time, in 8 hexadecimal digits.
let now = UInt32(DispatchWallTime.now().millisSinceEpoch / 1000)
let now = UInt32(LambdaClock().now.millisecondsSinceEpoch() / 1000)
let dateValue = String(now, radix: 16, uppercase: false)
let datePadding = String(repeating: "0", count: max(0, 8 - dateValue.count))
// A 96-bit identifier for the trace, globally unique, in 24 hexadecimal digits.
@@ -0,0 +1,139 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftAWSLambdaRuntime open source project
//
// Copyright (c) 2025 Apple Inc. and the SwiftAWSLambdaRuntime project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import Testing
@testable import AWSLambdaRuntime
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
@Suite("LambdaClock Tests")
struct LambdaClockTests {
@Test("Clock provides current time")
func clockProvidesCurrentTime() {
let clock = LambdaClock()
let now = clock.now
// Verify we get a reasonable timestamp (after today)
let dateOfWritingThisTestInMillis: Int64 = 1_754_130_134_000
#expect(now.instant > dateOfWritingThisTestInMillis)
}
@Test("Instant can be advanced by duration")
func instantCanBeAdvancedByDuration() {
let clock = LambdaClock()
let start = clock.now
let advanced = start.advanced(by: .seconds(30))
#expect(advanced.instant == start.instant + 30_000)
}
@Test("Duration calculation between instants")
func durationCalculationBetweenInstants() {
let clock = LambdaClock()
let start = clock.now
let end = start.advanced(by: .seconds(5))
let duration = start.duration(to: end)
#expect(duration == .seconds(5))
}
@Test("Instant comparison works correctly")
func instantComparisonWorksCorrectly() {
let clock = LambdaClock()
let earlier = clock.now
let later = earlier.advanced(by: .milliseconds(1))
#expect(earlier < later)
#expect(!(later < earlier))
}
@Test("Clock minimum resolution is milliseconds")
func clockMinimumResolutionIsMilliseconds() {
let clock = LambdaClock()
#expect(clock.minimumResolution == .milliseconds(1))
}
@Test("Sleep until deadline works")
func sleepUntilDeadlineWorks() async throws {
let clock = LambdaClock()
let start = clock.now
let deadline = start.advanced(by: .milliseconds(50))
try await clock.sleep(until: deadline, tolerance: nil)
let end = clock.now
let elapsed = start.duration(to: end)
// Allow some tolerance for timing precision
#expect(elapsed >= .milliseconds(40))
#expect(elapsed <= .milliseconds(100))
}
@Test("Sleep with past deadline returns immediately")
func sleepWithPastDeadlineReturnsImmediately() async throws {
let clock = LambdaClock()
let now = clock.now
let pastDeadline = now.advanced(by: .milliseconds(-100))
let start = clock.now
try await clock.sleep(until: pastDeadline, tolerance: nil)
let end = clock.now
let elapsed = start.duration(to: end)
// Should return almost immediately
#expect(elapsed < .milliseconds(10))
}
@Test("Duration to future instant returns negative duration")
func durationToFutureInstantReturnsNegativeDuration() {
let clock = LambdaClock()
let futureDeadline = clock.now.advanced(by: .seconds(30))
let currentTime = clock.now
// This simulates getRemainingTime() where deadline is in future
let remainingTime = futureDeadline.duration(to: currentTime)
// Should be negative since we're going from future to present
#expect(remainingTime < .zero)
#expect(remainingTime <= .seconds(-29)) // Allow some timing tolerance
}
@Test("LambdaClock now matches Foundation Date within tolerance")
func lambdaClockNowMatchesFoundationDate() {
let clock = LambdaClock()
// Get timestamps as close together as possible
let lambdaClockNow = clock.now
let foundationDate = Date()
// Convert Foundation Date to milliseconds since epoch
let foundationMillis = Int64(foundationDate.timeIntervalSince1970 * 1000)
let lambdaClockMillis = lambdaClockNow.millisecondsSinceEpoch()
// Allow small tolerance for timing differences between calls
let difference = abs(foundationMillis - lambdaClockMillis)
#expect(
difference <= 10,
"LambdaClock and Foundation Date should be within 10ms of each other, difference was \(difference)ms"
)
}
}
@@ -13,6 +13,7 @@
//===----------------------------------------------------------------------===//
import Foundation
import Logging
import Testing
@testable import AWSLambdaRuntime
@@ -111,4 +112,25 @@ struct LambdaContextTests {
#expect(environment["platform"] == "Android")
#expect(environment["platform_version"] == "10")
}
@Test("getRemainingTime returns positive duration for future deadline")
func getRemainingTimeReturnsPositiveDurationForFutureDeadline() {
// Create context with deadline 30 seconds in the future
let context = LambdaContext.__forTestsOnly(
requestID: "test-request",
traceID: "test-trace",
invokedFunctionARN: "test-arn",
timeout: .seconds(30),
logger: Logger(label: "test")
)
// Get remaining time - should be positive since deadline is in future
let remainingTime = context.getRemainingTime()
// Verify Duration can be negative (not absolute value)
#expect(remainingTime > .zero, "getRemainingTime() should return positive duration when deadline is in future")
#expect(remainingTime <= Duration.seconds(31), "Remaining time should be approximately 30 seconds")
#expect(remainingTime >= Duration.seconds(-29), "Remaining time should be approximately -30 seconds")
}
}