Introduce metric.destroy() to enable lifecycle management (#17)
motivation: allow middleware libraries better control of metric object lifecycle to reduce potential memory footprint when using metrics libraries that use some kind of registry or cache (eg polling backends) changes: * Introduce metric.destroy() to enable lifecycle management * Add destroy functions to example impl in readme
This commit is contained in:
parent
6337a9f30f
commit
d9f63df00c
|
|
@ -4,3 +4,4 @@
|
|||
/*.xcodeproj
|
||||
.xcode
|
||||
.SourceKitten
|
||||
*.orig
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ class SimpleMetricsLibrary: MetricsFactory {
|
|||
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
|
||||
return ExampleTimer(label, dimensions)
|
||||
}
|
||||
|
||||
// implementation is stateless, so nothing to do on destroy calls
|
||||
func destroyCounter(_ handler: CounterHandler) {}
|
||||
func destroyRecorder(_ handler: RecorderHandler) {}
|
||||
func destroyTimer(_ handler: TimerHandler) {}
|
||||
|
||||
private class ExampleCounter: CounterHandler {
|
||||
init(_: String, _: [(String, String)]) {}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public protocol CounterHandler: AnyObject {
|
|||
/// - parameters:
|
||||
/// - by: Amount to increment by.
|
||||
func increment(by: Int64)
|
||||
|
||||
/// Reset the counter back to zero.
|
||||
func reset()
|
||||
}
|
||||
|
|
@ -77,6 +78,7 @@ public class Counter {
|
|||
public func reset() {
|
||||
self.handler.reset()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension Counter {
|
||||
|
|
@ -89,6 +91,13 @@ public extension Counter {
|
|||
let handler = MetricsSystem.factory.makeCounter(label: label, dimensions: dimensions)
|
||||
self.init(label: label, dimensions: dimensions, handler: handler)
|
||||
}
|
||||
|
||||
/// Signal the underlying metrics library that this counter will never be updated again.
|
||||
/// In response library MAY decide to eagerly release any resources held by this counter.
|
||||
@inlinable
|
||||
func destroy() {
|
||||
MetricsSystem.factory.destroyCounter(self.handler)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `RecorderHandler` represents a backend implementation of a `Recorder`.
|
||||
|
|
@ -169,6 +178,13 @@ public extension Recorder {
|
|||
let handler = MetricsSystem.factory.makeRecorder(label: label, dimensions: dimensions, aggregate: aggregate)
|
||||
self.init(label: label, dimensions: dimensions, aggregate: aggregate, handler: handler)
|
||||
}
|
||||
|
||||
/// Signal the underlying metrics library that this recorder will never be updated again.
|
||||
/// In response library MAY decide to eagerly release any resources held by this recorder.
|
||||
@inlinable
|
||||
func destroy() {
|
||||
MetricsSystem.factory.destroyRecorder(self.handler)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Gauge is a metric that represents a single numerical value that can arbitrarily go up and down.
|
||||
|
|
@ -302,6 +318,13 @@ public extension Timer {
|
|||
let handler = MetricsSystem.factory.makeTimer(label: label, dimensions: dimensions)
|
||||
self.init(label: label, dimensions: dimensions, handler: handler)
|
||||
}
|
||||
|
||||
/// Signal the underlying metrics library that this timer will never be updated again.
|
||||
/// In response library MAY decide to eagerly release any resources held by this timer.
|
||||
@inlinable
|
||||
func destroy() {
|
||||
MetricsSystem.factory.destroyTimer(self.handler)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `MetricsFactory` is the bridge between the `MetricsSystem` and the metrics backend implementation.
|
||||
|
|
@ -312,6 +335,22 @@ public extension Timer {
|
|||
///
|
||||
/// This type is an implementation detail and should not be used directly, unless implementing your own metrics backend.
|
||||
/// To use the SwiftMetrics API, please refer to the documentation of `MetricsSystem`.
|
||||
///
|
||||
/// # Destroying metrics
|
||||
///
|
||||
/// Since _some_ metrics implementations may need to allocate (potentially "heavy") resources for metrics, destroying
|
||||
/// metrics offers a signal to libraries when a metric is "known to never be updated again."
|
||||
///
|
||||
/// While many metrics are bound to the entire lifetime of an application and thus never need to be destroyed eagerly,
|
||||
/// some metrics have well defined unique life-cycles, and it may be beneficial to release any resources held by them
|
||||
/// more eagerly than awaiting the applications termination. In such cases, a library or application should invoke
|
||||
/// a metric's appropriate `destroy()` method, which in turn results in the corresponding handler that it is backed by
|
||||
/// to be passed to `destroyCounter(handler:)`, `destroyRecorder(handler:)` or `destroyTimer(handler:)` where the factory
|
||||
/// can decide to free any corresponding resources.
|
||||
///
|
||||
/// While some libraries may not need to implement this destroying as they may be stateless or similar,
|
||||
/// libraries using the metrics API should always assume a library WILL make use of this signal, and shall not
|
||||
/// neglect calling these methods when appropriate.
|
||||
public protocol MetricsFactory {
|
||||
/// Create a backing `CounterHandler`.
|
||||
///
|
||||
|
|
@ -319,6 +358,7 @@ public protocol MetricsFactory {
|
|||
/// - label: The label for the CounterHandler.
|
||||
/// - dimensions: The dimensions for the CounterHandler.
|
||||
func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler
|
||||
|
||||
/// Create a backing `RecorderHandler`.
|
||||
///
|
||||
/// - parameters:
|
||||
|
|
@ -326,12 +366,34 @@ public protocol MetricsFactory {
|
|||
/// - dimensions: The dimensions for the RecorderHandler.
|
||||
/// - aggregate: Is data aggregation expected.
|
||||
func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler
|
||||
|
||||
/// Create a backing `TimerHandler`.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - label: The label for the TimerHandler.
|
||||
/// - dimensions: The dimensions for the TimerHandler.
|
||||
func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler
|
||||
|
||||
/// Invoked when the corresponding `Counter`'s `destroy()` function is invoked.
|
||||
/// Upon receiving this signal the factory may eagerly release any resources related to this counter.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - handler: The handler to be destroyed.
|
||||
func destroyCounter(_ handler: CounterHandler)
|
||||
|
||||
/// Invoked when the corresponding `Recorder`'s `destroy()` function is invoked.
|
||||
/// Upon receiving this signal the factory may eagerly release any resources related to this recorder.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - handler: The handler to be destroyed.
|
||||
func destroyRecorder(_ handler: RecorderHandler)
|
||||
|
||||
/// Invoked when the corresponding `Timer`'s `destroy()` function is invoked.
|
||||
/// Upon receiving this signal the factory may eagerly release any resources related to this timer.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - handler: The handler to be destroyed.
|
||||
func destroyTimer(_ handler: TimerHandler)
|
||||
}
|
||||
|
||||
/// The `MetricsSystem` is a global facility where the default metrics backend implementation (`MetricsFactory`) can be
|
||||
|
|
@ -363,7 +425,7 @@ public enum MetricsSystem {
|
|||
}
|
||||
}
|
||||
|
||||
/// Returns a refernece to the configured factory.
|
||||
/// Returns a reference to the configured factory.
|
||||
public static var factory: MetricsFactory {
|
||||
return self.lock.withReaderLock { self._factory }
|
||||
}
|
||||
|
|
@ -388,6 +450,25 @@ public final class MultiplexMetricsHandler: MetricsFactory {
|
|||
return MuxTimer(factories: self.factories, label: label, dimensions: dimensions)
|
||||
}
|
||||
|
||||
public func destroyCounter(_ handler: CounterHandler) {
|
||||
for factory in self.factories {
|
||||
factory.destroyCounter(handler)
|
||||
}
|
||||
}
|
||||
|
||||
public func destroyRecorder(_ handler: RecorderHandler) {
|
||||
for factory in self.factories {
|
||||
factory.destroyRecorder(handler)
|
||||
}
|
||||
}
|
||||
|
||||
public func destroyTimer(_ handler: TimerHandler) {
|
||||
for factory in self.factories {
|
||||
factory.destroyTimer(handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class MuxCounter: CounterHandler {
|
||||
let counters: [CounterHandler]
|
||||
public init(factories: [MetricsFactory], label: String, dimensions: [(String, String)]) {
|
||||
|
|
@ -432,6 +513,7 @@ public final class MultiplexMetricsHandler: MetricsFactory {
|
|||
|
||||
/// Ships with the metrics module, used for initial bootstraping.
|
||||
public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderHandler, TimerHandler {
|
||||
|
||||
public static let instance = NOOPMetricsHandler()
|
||||
|
||||
private init() {}
|
||||
|
|
@ -439,15 +521,17 @@ public final class NOOPMetricsHandler: MetricsFactory, CounterHandler, RecorderH
|
|||
public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler {
|
||||
return self
|
||||
}
|
||||
|
||||
public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler {
|
||||
return self
|
||||
}
|
||||
|
||||
public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler {
|
||||
return self
|
||||
}
|
||||
|
||||
public func destroyCounter(_ handler: CounterHandler) {}
|
||||
public func destroyRecorder(_ handler: RecorderHandler) {}
|
||||
public func destroyTimer(_ handler: TimerHandler) {}
|
||||
|
||||
public func increment(by: Int64) {}
|
||||
public func reset() {}
|
||||
public func record(_: Int64) {}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ extension MetricsTests {
|
|||
("testGaugeBlock", testGaugeBlock),
|
||||
("testMUX", testMUX),
|
||||
("testCustomFactory", testCustomFactory),
|
||||
("testDestroyingGauge", testDestroyingGauge),
|
||||
("testDestroyingCounter", testDestroyingCounter),
|
||||
("testDestroyingTimer", testDestroyingTimer),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,4 +240,93 @@ class MetricsTests: XCTestCase {
|
|||
let counter2 = Counter(label: "foo", dimensions: [], handler: CustomHandler())
|
||||
XCTAssertTrue(counter2.handler is CustomHandler, "expected custom log handler")
|
||||
}
|
||||
|
||||
func testDestroyingGauge() throws {
|
||||
let metrics = TestMetrics()
|
||||
MetricsSystem.bootstrapInternal(metrics)
|
||||
|
||||
let name = "gauge-\(NSUUID().uuidString)"
|
||||
let value = Double.random(in: -1000 ... 1000)
|
||||
|
||||
let gauge = Gauge(label: name)
|
||||
gauge.record(value)
|
||||
|
||||
let recorder = gauge.handler as! TestRecorder
|
||||
XCTAssertEqual(recorder.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(recorder.values.first!.1, value, "expected value to match")
|
||||
XCTAssertEqual(metrics.recorders.count, 1, "recorder should have been stored")
|
||||
|
||||
let identity = ObjectIdentifier(recorder)
|
||||
gauge.destroy()
|
||||
XCTAssertEqual(metrics.recorders.count, 0, "recorder should have been released")
|
||||
|
||||
let gaugeAgain = Gauge(label: name)
|
||||
gaugeAgain.record(-value)
|
||||
|
||||
let recorderAgain = gaugeAgain.handler as! TestRecorder
|
||||
XCTAssertEqual(recorderAgain.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(recorderAgain.values.first!.1, -value, "expected value to match")
|
||||
|
||||
let identityAgain = ObjectIdentifier(recorderAgain)
|
||||
XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity")
|
||||
}
|
||||
|
||||
func testDestroyingCounter() throws {
|
||||
let metrics = TestMetrics()
|
||||
MetricsSystem.bootstrapInternal(metrics)
|
||||
|
||||
let name = "counter-\(NSUUID().uuidString)"
|
||||
let value = Int.random(in: 0 ... 1000)
|
||||
|
||||
let counter = Counter(label: name)
|
||||
counter.increment(by: value)
|
||||
|
||||
let testCounter = counter.handler as! TestCounter
|
||||
XCTAssertEqual(testCounter.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(testCounter.values.first!.1, Int64(value), "expected value to match")
|
||||
XCTAssertEqual(metrics.counters.count, 1, "counter should have been stored")
|
||||
|
||||
let identity = ObjectIdentifier(counter)
|
||||
counter.destroy()
|
||||
XCTAssertEqual(metrics.counters.count, 0, "counter should have been released")
|
||||
|
||||
let counterAgain = Counter(label: name)
|
||||
counterAgain.increment(by: value)
|
||||
|
||||
let testCounterAgain = counterAgain.handler as! TestCounter
|
||||
XCTAssertEqual(testCounterAgain.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(testCounterAgain.values.first!.1, Int64(value), "expected value to match")
|
||||
|
||||
let identityAgain = ObjectIdentifier(counterAgain)
|
||||
XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity")
|
||||
}
|
||||
|
||||
func testDestroyingTimer() throws {
|
||||
let metrics = TestMetrics()
|
||||
MetricsSystem.bootstrapInternal(metrics)
|
||||
|
||||
let name = "timer-\(NSUUID().uuidString)"
|
||||
let value = Int64.random(in: 0 ... 1000)
|
||||
|
||||
let timer = Timer(label: name)
|
||||
timer.recordNanoseconds(value)
|
||||
|
||||
let testTimer = timer.handler as! TestTimer
|
||||
XCTAssertEqual(testTimer.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(testTimer.values.first!.1, value, "expected value to match")
|
||||
XCTAssertEqual(metrics.timers.count, 1, "timer should have been stored")
|
||||
|
||||
let identity = ObjectIdentifier(timer)
|
||||
timer.destroy()
|
||||
XCTAssertEqual(metrics.timers.count, 0, "timer should have been released")
|
||||
|
||||
let timerAgain = Timer(label: name)
|
||||
timerAgain.recordNanoseconds(value)
|
||||
let testTimerAgain = timerAgain.handler as! TestTimer
|
||||
XCTAssertEqual(testTimerAgain.values.count, 1, "expected number of entries to match")
|
||||
XCTAssertEqual(testTimerAgain.values.first!.1, value, "expected value to match")
|
||||
|
||||
let identityAgain = ObjectIdentifier(timerAgain)
|
||||
XCTAssertNotEqual(identity, identityAgain, "since the cached metric was released, the created a new should have a different identity")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@
|
|||
@testable import class CoreMetrics.Timer
|
||||
import Foundation
|
||||
|
||||
internal class TestMetrics: MetricsFactory {
|
||||
private let lock = NSLock() // TODO: consider lock per cache?
|
||||
/// Metrics factory which allows inspecting recorded metrics programmatically.
|
||||
/// Only intended for tests of the Metrics API itself.
|
||||
internal final class TestMetrics: MetricsFactory {
|
||||
private let lock = NSLock()
|
||||
var counters = [String: CounterHandler]()
|
||||
var recorders = [String: RecorderHandler]()
|
||||
var timers = [String: TimerHandler]()
|
||||
|
|
@ -38,12 +40,30 @@ internal class TestMetrics: MetricsFactory {
|
|||
}
|
||||
|
||||
private func make<Item>(label: String, dimensions: [(String, String)], registry: inout [String: Item], maker: (String, [(String, String)]) -> Item) -> Item {
|
||||
let item = maker(label, dimensions)
|
||||
return self.lock.withLock {
|
||||
let item = maker(label, dimensions)
|
||||
registry[label] = item
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
func destroyCounter(_ handler: CounterHandler) {
|
||||
if let testCounter = handler as? TestCounter {
|
||||
self.counters.removeValue(forKey: testCounter.label)
|
||||
}
|
||||
}
|
||||
|
||||
func destroyRecorder(_ handler: RecorderHandler) {
|
||||
if let testRecorder = handler as? TestRecorder {
|
||||
self.recorders.removeValue(forKey: testRecorder.label)
|
||||
}
|
||||
}
|
||||
|
||||
func destroyTimer(_ handler: TimerHandler) {
|
||||
if let testTimer = handler as? TestTimer {
|
||||
self.timers.removeValue(forKey: testTimer.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class TestCounter: CounterHandler, Equatable {
|
||||
|
|
|
|||
Loading…
Reference in New Issue