Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ListenMetricsEntryFileJournal
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 5
420
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 appendEntry
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 archiveCurrentMetricsJournal
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
90
 getCurrentMetricsJournalFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 flockWithTimeout
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Wikispeech\Api;
4
5/**
6 * @file
7 * @ingroup API
8 * @ingroup Extensions
9 * @license GPL-2.0-or-later
10 */
11
12use Config;
13use InvalidArgumentException;
14use MediaWiki\Logger\LoggerFactory;
15use Psr\Log\LoggerInterface;
16
17/**
18 * Adds journal entries as single lines of JSON in a file on the filesystem.
19 *
20 * @since 0.1.10
21 */
22class ListenMetricsEntryFileJournal implements ListenMetricsEntryJournal {
23
24    /** @var LoggerInterface */
25    private $logger;
26
27    /** @var Config */
28    private $config;
29
30    /**
31     * @since 0.1.10
32     * @param Config $config
33     */
34    public function __construct( Config $config ) {
35        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
36        $this->config = $config;
37    }
38
39    /**
40     * @since 0.1.10
41     * @param ListenMetricsEntry $entry
42     */
43    public function appendEntry( ListenMetricsEntry $entry ): void {
44        /**
45         * @var float
46         * One second sounds like much, but there is no queuing here, it's optimistic locking.
47         * We really need to give it a bit of time in case of really heavy user load.
48         */
49        $lockTimeoutSeconds = 1;
50        $metricsJournalFile = $this->getCurrentMetricsJournalFile();
51        $metricsSerializer = new ListenMetricsEntrySerializer();
52        $json = json_encode( $metricsSerializer->serialize( $entry ) );
53        $fh = fopen( $metricsJournalFile, 'a' );
54        if ( $this->flockWithTimeout( $fh, LOCK_EX, $lockTimeoutSeconds * 1000000 ) ) {
55            try {
56                fwrite( $fh, $json );
57                fwrite( $fh, "\n" );
58                fflush( $fh );
59            } finally {
60                flock( $fh, LOCK_UN );
61            }
62        } else {
63            $this->logger->warning( 'Unable to get write lock on {metricsJournalFile}', [
64                'file' => $metricsJournalFile
65            ] );
66        }
67        fclose( $fh );
68
69        // @todo switch to database?
70        // $crud = new ListenMetricsEntryCrud( $this->dbLoadBalancer );
71        // $crud->create( $this->listenMetricEntry );
72    }
73
74    /**
75     * In case of an empty or missing journal file, the function returns false.
76     * Attempts to rename current metrics journal file with an appended ISO8601 date/timestamp.
77     * If this is successful the function returns true.
78     * It then attempts to gzip-compress and delete the uncompressed file.
79     * Even if any of these actions fails the method will return true
80     * but it will produce warnings in the log.
81     *
82     * @since 0.1.10
83     * @return bool Whether or not the current journal was archived. If false, see log.
84     */
85    public function archiveCurrentMetricsJournal(): bool {
86        $currentMetricsJournalFile = $this->getCurrentMetricsJournalFile();
87        if ( !file_exists( $currentMetricsJournalFile ) ) {
88            $this->logger->info( __METHOD__ .
89                'Attempted to archive non existing journal file {file}',
90                [ 'file' => $currentMetricsJournalFile ]
91            );
92            return false;
93        }
94        if ( !filesize( $currentMetricsJournalFile ) ) {
95            $this->logger->info( __METHOD__ .
96                'Attempted to archive an empty journal file {file}',
97                [ 'file' => $currentMetricsJournalFile ]
98            );
99            return false;
100        }
101
102        $archivedMetricsJournalFile = $currentMetricsJournalFile . '.' . date( 'c' );
103        /**
104         * @var float
105         * Here we can give it quite a bit of time to lock. No worries.
106         */
107        $lockTimeoutSeconds = 10;
108        $lockHandler = fopen( $currentMetricsJournalFile, 'r' );
109        if ( !$this->flockWithTimeout( $lockHandler, LOCK_EX, 1000000 * $lockTimeoutSeconds ) ) {
110            $this->logger->warning( __METHOD__ .
111                'Unable to achieve file lock on {file}',
112                [ 'file' => $currentMetricsJournalFile ]
113            );
114            return false;
115        }
116        try {
117            if ( !rename( $currentMetricsJournalFile, $archivedMetricsJournalFile ) ) {
118                $this->logger->error( __METHOD__ .
119                    'Unable to rename existing file {from} to {to}',
120                    [
121                        'from' => $currentMetricsJournalFile,
122                        'to' => $archivedMetricsJournalFile
123                    ]
124                );
125                return false;
126            }
127        } finally {
128            flock( $lockHandler, LOCK_UN );
129            fclose( $lockHandler );
130        }
131
132        $gzippedArchivedMetricsJournalFile = $archivedMetricsJournalFile . '.gz';
133        // wb9 = write binary, compression level 9
134        $out = gzopen( $gzippedArchivedMetricsJournalFile, 'wb9' );
135        if ( $out ) {
136            $in = fopen( $archivedMetricsJournalFile, 'rb' );
137            if ( $in ) {
138                $bufferLength = 1024 * 512;
139                while ( !feof( $in ) ) {
140                    gzwrite( $out, fread( $in, $bufferLength ) );
141                }
142                fclose( $in );
143            } else {
144                $this->logger->warning( __METHOD__ .
145                    'Unable to read from file {from}. Archived journal was not compressed.',
146                    [ 'from' => $archivedMetricsJournalFile ]
147                );
148                return true;
149            }
150            gzclose( $out );
151        } else {
152            $this->logger->warning( __METHOD__ .
153                'Unable to open new file {file} for compression output. Archived journal was not compressed.',
154                [ 'from' => $archivedMetricsJournalFile ]
155            );
156            return true;
157        }
158        if ( !unlink( $archivedMetricsJournalFile ) ) {
159            $this->logger->warning( __METHOD__ .
160                'Unable to delete uncompressed archived journal file {file}.',
161                [ 'file' => $archivedMetricsJournalFile ]
162            );
163        }
164        return true;
165    }
166
167    /**
168     * @since 0.1.10
169     * @return string
170     */
171    private function getCurrentMetricsJournalFile(): string {
172        $metricsJournalFile = $this->config->get( 'WikispeechListenMetricsJournalFile' );
173        if ( !$metricsJournalFile ) {
174            $metricsJournalFile = "{$this->config->get( 'UploadDirectory' )}/wikispeechListenMetrics.log";
175        }
176        return $metricsJournalFile;
177    }
178
179    /**
180     * https://gist.github.com/CMCDragonkai/a7b446f15094f59083a2
181     *
182     * Acquires a lock using flock, provide it a file stream, the
183     * lock type, a timeout in microseconds, and a sleep_by in microseconds.
184     * PHP's flock does not currently have a timeout or queuing mechanism.
185     * So we have to hack a optimistic method of continuously sleeping
186     * and retrying to acquire the lock until we reach a timeout.
187     * Doing this in microseconds is a good idea, as seconds are too
188     * granular and can allow a new thread to cheat the queue.
189     * There's no actual queue of locks being implemented here, so
190     * it is fundamentally non-deterministic when multiple threads
191     * try to acquire a lock with a timeout.
192     * This means a possible failure is resource starvation.
193     * For example, if there's too many concurrent threads competing for
194     * a lock, then this implementation may allow the second thread to be
195     * starved and allow the third thread to acquire the lock.
196     * The trick here is in the combination of LOCK_NB and $blocking.
197     * The $blocking variable is assigned by reference, it returns 1
198     * when the flock is blocked from acquiring a lock. With LOCK_NB
199     * the flock returns immediately instead of waiting indefinitely.
200     *
201     * @param resource $lockfile Lock file resource that is opened.
202     * @param int $lockType LOCK_EX or LOCK_SH
203     * @param int $timeout_micro In microseconds, where 1 second = 1,000,000 microseconds
204     * @param int $sleep_by_micro Microsecond sleep period, by default 0.01 of a second
205     * @return bool
206     */
207    private function flockWithTimeout(
208        $lockfile,
209        int $lockType,
210        int $timeout_micro,
211        int $sleep_by_micro = 10000
212    ): bool {
213        // @todo phpcs is not a fan of is_resource. What do we use instead?
214        //if ( !is_resource( $lockfile ) ) {
215        //    throw new InvalidArgumentException(
216        //        'The $lockfile was not a file resource or the resource was closed.'
217        //    );
218        //}
219        if ( $sleep_by_micro < 1 ) {
220            throw new InvalidArgumentException(
221                'The $sleep_by_micro cannot be less than 1, or else an infinite loop.'
222            );
223        }
224        if ( $timeout_micro < 1 ) {
225            $locked = flock( $lockfile, $lockType | LOCK_NB );
226        } else {
227            $count_micro = 0;
228            $locked = true;
229            while ( !flock( $lockfile, $lockType | LOCK_NB, $blocking ) ) {
230                if ( $blocking ) {
231                    $count_micro += $sleep_by_micro;
232                    if ( $count_micro <= $timeout_micro ) {
233                        usleep( $sleep_by_micro );
234                    }
235                } else {
236                    $locked = false;
237                    break;
238                }
239            }
240        }
241        return $locked;
242    }
243}