Sources/XCMetricsBackendLib/Statistics/Repositories/SQLStatisticsRepository.swift (118 lines of code) (raw):

// Copyright (c) 2021 Spotify AB. // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. import Foundation import Fluent import FluentSQL import NIO private struct CountResult: Decodable { var builds: Int; var errors: Int; } class SQLStatisticsRepository: StatisticsRepository { let db: Database init(db: Database) { self.db = db; } // MARK: - Build Status func getBuildStatuses(page: Int, per: Int, using eventLoop: EventLoop) -> EventLoopFuture<Page<BuildStatusResult>> { return Build.query(on: self.db) .field(\.$id) .field(\.$buildStatus) .sort(\.$startTimestampMicroseconds, .descending) .paginate(PageRequest(page: page, per: per)) .map { $0.map { BuildStatusResult(id: $0.id!, buildStatus: $0.buildStatus) } } } // MARK: - Day Count func getDayCounts(from: Date, to: Date, using eventLoop: EventLoop) -> EventLoopFuture<[DayCount]> { return DayCount.query(on: db) .filter(\.$id >= from) .filter(\.$id <= to) .sort(\.$id) .all() .flatMap { counts in return eventLoop.makeSucceededFuture( self.fillDays(dayStatistics: counts, from: from, to: to) ) } } func getCount(day: Date, using eventLoop: EventLoop) -> EventLoopFuture<DayCount> { guard let sql = db as? SQLDatabase else { return eventLoop.makeFailedFuture(RepositoryError.unexpected(message: "The database is not SQL")) } let query: SQLQueryString = """ SELECT sum(error_count) as errors, count(*) as builds FROM \(raw: Build.schema)_\(raw: day.xcm_toPartitionedTableFormat()) """ return sql.raw(query) .first(decoding: CountResult.self) .flatMapAlways { result in let count = (try? result.get()) ?? CountResult(builds: 0, errors: 0) return eventLoop.makeSucceededFuture(DayCount(day: day, builds: count.builds, errors: count.errors)) } } func createDayCount(day: Date, using eventLoop: EventLoop) -> EventLoopFuture<Void> { return self.getCount(day: day, using: eventLoop).flatMap { count in count.create(on: self.db) } } // MARK: - Day Build Times func getDayBuildTimes(from: Date, to: Date, using eventLoop: EventLoop) -> EventLoopFuture<[DayBuildTime]> { return DayBuildTime.query(on: db) .filter(\.$id >= from) .filter(\.$id <= to) .sort(\.$id) .all() .flatMap { times in return eventLoop.makeSucceededFuture( self.fillDays(dayStatistics: times, from: from, to: to) ) } } func getBuildTime(day: Date, using eventLoop: EventLoop) -> EventLoopFuture<DayBuildTime> { var durations: EventLoopFuture<[Double]>; if let sql = db as? SQLDatabase { // Optimization to speed up queries if we're using tables sharded by day durations = sql.select() .column("duration") .from("\(Build.schema)_\(day.xcm_toPartitionedTableFormat())") .orderBy("duration") .all() .flatMapError { _ in return eventLoop.makeSucceededFuture([]) } .mapEach { (try? $0.decode(column: "duration", as: Double.self)) ?? 0 } } else { durations = Build.query(on: db).filter(\.$day == day).sort(\.$duration).all(\.$duration) } return durations.flatMap { durations in let count = Float(durations.count) guard durations.count > 0 else { return eventLoop.makeSucceededFuture( DayBuildTime(day: day, durationP50: 0, durationP95: 0, totalDuration: 0) ) } // Nearest-rank percentiles let durationP50 = durations[Int((0.50 * count).rounded(.up)) - 1] let durationP95 = durations[Int((0.95 * count).rounded(.up)) - 1] let totalDuration = durations.reduce(0, +) return eventLoop.makeSucceededFuture( DayBuildTime(day: day, durationP50: durationP50, durationP95: durationP95, totalDuration: totalDuration) ) } } func createDayBuildTime(day: Date, using eventLoop: EventLoop) -> EventLoopFuture<Void> { return self.getBuildTime(day: day, using: eventLoop).flatMap { time in time.create(on: self.db) } } // MARK: - Private Methods /// Since days without any build information potentially could exist (for instance if a job has failed or not been run) /// we need to fill days without build information with zero values to keep the format consistent. /// Performance of this method should be considered for large date ranges. private func fillDays<T: DayData>(dayStatistics: [T], from: Date, to: Date) -> [T] { let from = from.xcm_truncateTime() let days = Calendar.current.dateComponents([.day], from: from, to: to.xcm_truncateTime()).day! + 1 guard days > 0 && dayStatistics.count != days else { return dayStatistics } var filled = [T]() for dayOffset in 0..<days { let day = Calendar.current.date(byAdding: .day, value: dayOffset, to: from)! if let dayStatistic = dayStatistics.first(where: { $0.id == day }) { filled.append(dayStatistic) } else { filled.append(T(day: day)) } } return filled } }