diff --git a/Sources/AWSLambdaRuntime/FoundationSupport/Context+Foundation.swift b/Sources/AWSLambdaRuntime/FoundationSupport/Context+Foundation.swift index 72b7a65d..fad7dcec 100644 --- a/Sources/AWSLambdaRuntime/FoundationSupport/Context+Foundation.swift +++ b/Sources/AWSLambdaRuntime/FoundationSupport/Context+Foundation.swift @@ -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 diff --git a/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift b/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift index f536e3f4..20163633 100644 --- a/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift +++ b/Sources/AWSLambdaRuntime/Lambda+LocalServer.swift @@ -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( diff --git a/Sources/AWSLambdaRuntime/Lambda.swift b/Sources/AWSLambdaRuntime/Lambda.swift index 5412c139..706fe567 100644 --- a/Sources/AWSLambdaRuntime/Lambda.swift +++ b/Sources/AWSLambdaRuntime/Lambda.swift @@ -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 ) diff --git a/Sources/AWSLambdaRuntime/LambdaClock.swift b/Sources/AWSLambdaRuntime/LambdaClock.swift new file mode 100644 index 00000000..6f3df393 --- /dev/null +++ b/Sources/AWSLambdaRuntime/LambdaClock.swift @@ -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) + } +} diff --git a/Sources/AWSLambdaRuntime/LambdaContext.swift b/Sources/AWSLambdaRuntime/LambdaContext.swift index 81fa72b9..0407f9e6 100644 --- a/Sources/AWSLambdaRuntime/LambdaContext.swift +++ b/Sources/AWSLambdaRuntime/LambdaContext.swift @@ -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 ) } diff --git a/Sources/AWSLambdaRuntime/Utils.swift b/Sources/AWSLambdaRuntime/Utils.swift index aa36fd76..8ab544c8 100644 --- a/Sources/AWSLambdaRuntime/Utils.swift +++ b/Sources/AWSLambdaRuntime/Utils.swift @@ -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. diff --git a/Tests/AWSLambdaRuntimeTests/LambdaClockTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaClockTests.swift new file mode 100644 index 00000000..413a3aa2 --- /dev/null +++ b/Tests/AWSLambdaRuntimeTests/LambdaClockTests.swift @@ -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" + ) + } +} diff --git a/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift b/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift index c1108b68..827105da 100644 --- a/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift +++ b/Tests/AWSLambdaRuntimeTests/LambdaContextTests.swift @@ -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") + } }