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:
Konrad `ktoso` Malawski 2019-04-20 02:33:58 +09:00 committed by tomer doron
parent 6337a9f30f
commit d9f63df00c
6 changed files with 208 additions and 6 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
/*.xcodeproj
.xcode
.SourceKitten
*.orig

View File

@ -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)]) {}

View File

@ -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) {}

View File

@ -38,6 +38,9 @@ extension MetricsTests {
("testGaugeBlock", testGaugeBlock),
("testMUX", testMUX),
("testCustomFactory", testCustomFactory),
("testDestroyingGauge", testDestroyingGauge),
("testDestroyingCounter", testDestroyingCounter),
("testDestroyingTimer", testDestroyingTimer),
]
}
}

View File

@ -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")
}
}

View File

@ -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 {