Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.43% covered (success)
91.43%
96 / 105
73.33% covered (warning)
73.33%
11 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
WRStatsReader
91.43% covered (success)
91.43%
96 / 105
73.33% covered (warning)
73.33%
11 / 15
41.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 latest
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 timeRange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRate
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
7.03
 getRates
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 fetch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setCurrentTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetCurrentTime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 now
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 internalGetCount
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
10
 total
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 perSecond
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 perMinute
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 perHour
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 perDay
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace Wikimedia\WRStats;
4
5/**
6 * Readers gather a batch of read operations, returning
7 * promises. The batch is executed when the first promise is resolved.
8 *
9 * @since 1.39
10 */
11class WRStatsReader {
12    /** @var StatsStore */
13    private $store;
14    /** @var array<string,MetricSpec> */
15    private $metricSpecs;
16    /** @var string[] */
17    private $prefixComponents;
18    /** @var float|int|null The UNIX timestamp used for the current time */
19    private $now;
20    /** @var bool[] Storage keys ready to be fetched */
21    private $queuedKeys = [];
22    /** @var int[] Unscaled integers returned by the store, indexed by key */
23    private $cachedValues = [];
24
25    /**
26     * @internal Use WRStatsFactory::createReader instead
27     * @param StatsStore $store
28     * @param array<string,array> $specs
29     * @param string|string[] $prefix
30     */
31    public function __construct( StatsStore $store, $specs, $prefix ) {
32        $this->store = $store;
33        $this->metricSpecs = [];
34        foreach ( $specs as $name => $spec ) {
35            $this->metricSpecs[$name] = new MetricSpec( $spec );
36        }
37        $this->prefixComponents = is_array( $prefix ) ? $prefix : [ $prefix ];
38        if ( !count( $this->prefixComponents ) ) {
39            throw new WRStatsError( __METHOD__ .
40                ': there must be at least one prefix component' );
41        }
42    }
43
44    /**
45     * Get a TimeRange for some period ending at the current time. Note that
46     * this will use the same value of the current time for subsequent calls
47     * until resetCurrentTime() is called.
48     *
49     * @param int|float $numSeconds
50     * @return TimeRange
51     */
52    public function latest( $numSeconds ) {
53        $now = $this->now();
54        return new TimeRange( $now - $numSeconds, $now );
55    }
56
57    /**
58     * Get a specified time range
59     *
60     * @param int|float $start The UNIX time of the start of the range
61     * @param int|float $end The UNIX time of the end of the range
62     * @return TimeRange
63     */
64    public function timeRange( $start, $end ) {
65        return new TimeRange( $start, $end );
66    }
67
68    /**
69     * Queue a fetch operation.
70     *
71     * @param string $metricName The metric name, the key into $specs.
72     * @param EntityKey|null $entity Additional storage key components
73     * @param TimeRange $range The time range to fetch
74     * @return RatePromise
75     */
76    public function getRate( $metricName, ?EntityKey $entity, TimeRange $range ) {
77        $metricSpec = $this->metricSpecs[$metricName] ?? null;
78        if ( $metricSpec === null ) {
79            throw new WRStatsError( "Unrecognised metric \"$metricName\"" );
80        }
81        $entity ??= new LocalEntityKey;
82        $now = $this->now();
83        $seqSpec = null;
84        foreach ( $metricSpec->sequences as $seqSpec ) {
85            $seqStart = $now - $seqSpec->softExpiry;
86            if ( $seqStart <= $range->start ) {
87                break;
88            }
89        }
90
91        if ( !$seqSpec ) {
92            // This check exists to make Phan happy.
93            // It should never fail since we apply normalization in MetricSpec::__construct()
94            throw new WRStatsError( 'There should have been at least one sequence' );
95        }
96
97        $timeStep = $seqSpec->timeStep;
98        $firstBucket = (int)( $range->start / $timeStep );
99        $lastBucket = (int)ceil( $range->end / $timeStep );
100        for ( $bucket = $firstBucket; $bucket <= $lastBucket; $bucket++ ) {
101            $key = $this->store->makeKey(
102                $this->prefixComponents,
103                [ $metricName, $seqSpec->name, $bucket ],
104                $entity
105            );
106            if ( !isset( $this->cachedValues[$key] ) ) {
107                $this->queuedKeys[$key] = true;
108            }
109        }
110        return new RatePromise( $this, $metricName, $entity, $metricSpec, $seqSpec, $range );
111    }
112
113    /**
114     * Queue a batch of fetch operations for different metrics with the same
115     * time range.
116     *
117     * @param string[] $metricNames
118     * @param EntityKey|null $entity
119     * @param TimeRange $range
120     * @return RatePromise[]
121     */
122    public function getRates( $metricNames, ?EntityKey $entity, TimeRange $range ) {
123        $rates = [];
124        foreach ( $metricNames as $name ) {
125            $rates[$name] = $this->getRate( $name, $entity, $range );
126        }
127        return $rates;
128    }
129
130    /**
131     * Perform any queued fetch operations.
132     */
133    public function fetch() {
134        if ( !$this->queuedKeys ) {
135            return;
136        }
137        $this->cachedValues += $this->store->query( array_keys( $this->queuedKeys ) );
138        $this->queuedKeys = [];
139    }
140
141    /**
142     * Set the current time to be used in latest() etc.
143     *
144     * @param int|float $now
145     */
146    public function setCurrentTime( $now ) {
147        $this->now = $now;
148    }
149
150    /**
151     * Clear the current time so that it will be filled with the real current
152     * time on the next call.
153     */
154    public function resetCurrentTime() {
155        $this->now = null;
156    }
157
158    /**
159     * @return float|int
160     */
161    private function now() {
162        $this->now ??= microtime( true );
163        return $this->now;
164    }
165
166    /**
167     * @internal Utility for resolution in RatePromise
168     * @param string $metricName
169     * @param EntityKey $entity
170     * @param MetricSpec $metricSpec
171     * @param SequenceSpec $seqSpec
172     * @param TimeRange $range
173     * @return float|int
174     */
175    public function internalGetCount(
176        $metricName,
177        EntityKey $entity,
178        MetricSpec $metricSpec,
179        SequenceSpec $seqSpec,
180        TimeRange $range
181    ) {
182        $this->fetch();
183        $timeStep = $seqSpec->timeStep;
184        $firstBucket = (int)( $range->start / $timeStep );
185        $lastBucket = (int)( $range->end / $timeStep );
186        $now = $this->now();
187        $total = 0;
188        for ( $bucket = $firstBucket; $bucket <= $lastBucket; $bucket++ ) {
189            $key = $this->store->makeKey(
190                $this->prefixComponents,
191                [ $metricName, $seqSpec->name, $bucket ],
192                $entity
193            );
194            $value = $this->cachedValues[$key] ?? 0;
195            if ( !$value ) {
196                continue;
197            } elseif ( $bucket === $firstBucket ) {
198                if ( $bucket === $lastBucket ) {
199                    // It can be assumed that there are zero events in the future
200                    $bucketStartTime = $bucket * $timeStep;
201                    $rateInterpolationEndTime = min( $bucketStartTime + $timeStep, $now );
202                    $interpolationDuration = $rateInterpolationEndTime - $bucketStartTime;
203                    if ( $interpolationDuration > 0 ) {
204                        $total += $value * $range->getDuration() / $interpolationDuration;
205                    }
206                } else {
207                    $overlapDuration = max( ( $bucket + 1 ) * $timeStep - $range->start, 0 );
208                    $total += $value * $overlapDuration / $timeStep;
209                }
210            } elseif ( $bucket === $lastBucket ) {
211                // It can be assumed that there are zero events in the future
212                $bucketStartTime = $bucket * $timeStep;
213                $rateInterpolationEndTime = min( $bucketStartTime + $timeStep, $now );
214                $overlapDuration = max( $range->end - $bucketStartTime, 0 );
215                $interpolationDuration = $rateInterpolationEndTime - $bucketStartTime;
216                if ( $overlapDuration === $interpolationDuration ) {
217                    // Special case for 0/0 -- current time exactly on boundary.
218                    $total += $value;
219                } elseif ( $interpolationDuration > 0 ) {
220                    $total += $value * $overlapDuration / $interpolationDuration;
221                }
222            } else {
223                $total += $value;
224            }
225        }
226        // Round to nearest resolution step for nicer display
227        $rounded = round( $total ) * $metricSpec->resolution;
228        // Convert to integer if integer is expected
229        if ( is_int( $metricSpec->resolution ) ) {
230            $rounded = (int)$rounded;
231        }
232        return $rounded;
233    }
234
235    /**
236     * Resolve a batch of RatePromise objects, returning their counter totals,
237     * indexed as in the input array.
238     *
239     * @param array<mixed,RatePromise> $rates
240     * @return array<mixed,float|int>
241     */
242    public function total( $rates ) {
243        $result = [];
244        foreach ( $rates as $key => $rate ) {
245            $result[$key] = $rate->total();
246        }
247        return $result;
248    }
249
250    /**
251     * Resolve a batch of RatePromise objects, returning their per-second rates.
252     *
253     * @param array<mixed,RatePromise> $rates
254     * @return array<mixed,float>
255     */
256    public function perSecond( $rates ) {
257        $result = [];
258        foreach ( $rates as $key => $rate ) {
259            $result[$key] = $rate->perSecond();
260        }
261        return $result;
262    }
263
264    /**
265     * Resolve a batch of RatePromise objects, returning their per-minute rates.
266     *
267     * @param array<mixed,RatePromise> $rates
268     * @return array<mixed,float>
269     */
270    public function perMinute( $rates ) {
271        $result = [];
272        foreach ( $rates as $key => $rate ) {
273            $result[$key] = $rate->perMinute();
274        }
275        return $result;
276    }
277
278    /**
279     * Resolve a batch of RatePromise objects, returning their per-hour rates.
280     *
281     * @param array<mixed,RatePromise> $rates
282     * @return array<mixed,float>
283     */
284    public function perHour( $rates ) {
285        $result = [];
286        foreach ( $rates as $key => $rate ) {
287            $result[$key] = $rate->perHour();
288        }
289        return $result;
290    }
291
292    /**
293     * Resolve a batch of RatePromise objects, returning their per-day rates.
294     *
295     * @param array<mixed,RatePromise> $rates
296     * @return array<mixed,float>
297     */
298    public function perDay( $rates ) {
299        $result = [];
300        foreach ( $rates as $key => $rate ) {
301            $result[$key] = $rate->perDay();
302        }
303        return $result;
304    }
305}