Warn if stateful ParserOutput transforms are used
[lhc/web/wiklou.git] / includes / HistoryBlob.php
1 <?php
2 /**
3 * Efficient concatenated text storage.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 /**
24 * Base class for general text storage via the "object" flag in old_flags, or
25 * two-part external storage URLs. Used for represent efficient concatenated
26 * storage, and migration-related pointer objects.
27 */
28 interface HistoryBlob {
29 /**
30 * Adds an item of text, returns a stub object which points to the item.
31 * You must call setLocation() on the stub object before storing it to the
32 * database
33 *
34 * @param string $text
35 *
36 * @return string The key for getItem()
37 */
38 function addItem( $text );
39
40 /**
41 * Get item by key, or false if the key is not present
42 *
43 * @param string $key
44 *
45 * @return string|bool
46 */
47 function getItem( $key );
48
49 /**
50 * Set the "default text"
51 * This concept is an odd property of the current DB schema, whereby each text item has a revision
52 * associated with it. The default text is the text of the associated revision. There may, however,
53 * be other revisions in the same object.
54 *
55 * Default text is not required for two-part external storage URLs.
56 *
57 * @param string $text
58 */
59 function setText( $text );
60
61 /**
62 * Get default text. This is called from Revision::getRevisionText()
63 *
64 * @return string
65 */
66 function getText();
67 }
68
69 /**
70 * Concatenated gzip (CGZ) storage
71 * Improves compression ratio by concatenating like objects before gzipping
72 */
73 class ConcatenatedGzipHistoryBlob implements HistoryBlob {
74 public $mVersion = 0, $mCompressed = false, $mItems = [], $mDefaultHash = '';
75 public $mSize = 0;
76 public $mMaxSize = 10000000;
77 public $mMaxCount = 100;
78
79 public function __construct() {
80 if ( !function_exists( 'gzdeflate' ) ) {
81 throw new MWException( "Need zlib support to read or write this "
82 . "kind of history object (ConcatenatedGzipHistoryBlob)\n" );
83 }
84 }
85
86 /**
87 * @param string $text
88 * @return string
89 */
90 public function addItem( $text ) {
91 $this->uncompress();
92 $hash = md5( $text );
93 if ( !isset( $this->mItems[$hash] ) ) {
94 $this->mItems[$hash] = $text;
95 $this->mSize += strlen( $text );
96 }
97 return $hash;
98 }
99
100 /**
101 * @param string $hash
102 * @return array|bool
103 */
104 public function getItem( $hash ) {
105 $this->uncompress();
106 if ( array_key_exists( $hash, $this->mItems ) ) {
107 return $this->mItems[$hash];
108 } else {
109 return false;
110 }
111 }
112
113 /**
114 * @param string $text
115 * @return void
116 */
117 public function setText( $text ) {
118 $this->uncompress();
119 $this->mDefaultHash = $this->addItem( $text );
120 }
121
122 /**
123 * @return array|bool
124 */
125 public function getText() {
126 $this->uncompress();
127 return $this->getItem( $this->mDefaultHash );
128 }
129
130 /**
131 * Remove an item
132 *
133 * @param string $hash
134 */
135 public function removeItem( $hash ) {
136 $this->mSize -= strlen( $this->mItems[$hash] );
137 unset( $this->mItems[$hash] );
138 }
139
140 /**
141 * Compress the bulk data in the object
142 */
143 public function compress() {
144 if ( !$this->mCompressed ) {
145 $this->mItems = gzdeflate( serialize( $this->mItems ) );
146 $this->mCompressed = true;
147 }
148 }
149
150 /**
151 * Uncompress bulk data
152 */
153 public function uncompress() {
154 if ( $this->mCompressed ) {
155 $this->mItems = unserialize( gzinflate( $this->mItems ) );
156 $this->mCompressed = false;
157 }
158 }
159
160 /**
161 * @return array
162 */
163 function __sleep() {
164 $this->compress();
165 return [ 'mVersion', 'mCompressed', 'mItems', 'mDefaultHash' ];
166 }
167
168 function __wakeup() {
169 $this->uncompress();
170 }
171
172 /**
173 * Helper function for compression jobs
174 * Returns true until the object is "full" and ready to be committed
175 *
176 * @return bool
177 */
178 public function isHappy() {
179 return $this->mSize < $this->mMaxSize
180 && count( $this->mItems ) < $this->mMaxCount;
181 }
182 }
183
184 /**
185 * Pointer object for an item within a CGZ blob stored in the text table.
186 */
187 class HistoryBlobStub {
188 /**
189 * @var array One-step cache variable to hold base blobs; operations that
190 * pull multiple revisions may often pull multiple times from the same
191 * blob. By keeping the last-used one open, we avoid redundant
192 * unserialization and decompression overhead.
193 */
194 protected static $blobCache = [];
195
196 /** @var int */
197 public $mOldId;
198
199 /** @var string */
200 public $mHash;
201
202 /** @var string */
203 public $mRef;
204
205 /**
206 * @param string $hash The content hash of the text
207 * @param int $oldid The old_id for the CGZ object
208 */
209 function __construct( $hash = '', $oldid = 0 ) {
210 $this->mHash = $hash;
211 }
212
213 /**
214 * Sets the location (old_id) of the main object to which this object
215 * points
216 * @param int $id
217 */
218 function setLocation( $id ) {
219 $this->mOldId = $id;
220 }
221
222 /**
223 * Sets the location (old_id) of the referring object
224 * @param string $id
225 */
226 function setReferrer( $id ) {
227 $this->mRef = $id;
228 }
229
230 /**
231 * Gets the location of the referring object
232 * @return string
233 */
234 function getReferrer() {
235 return $this->mRef;
236 }
237
238 /**
239 * @return string|false
240 */
241 function getText() {
242 if ( isset( self::$blobCache[$this->mOldId] ) ) {
243 $obj = self::$blobCache[$this->mOldId];
244 } else {
245 $dbr = wfGetDB( DB_REPLICA );
246 $row = $dbr->selectRow(
247 'text',
248 [ 'old_flags', 'old_text' ],
249 [ 'old_id' => $this->mOldId ]
250 );
251
252 if ( !$row ) {
253 return false;
254 }
255
256 $flags = explode( ',', $row->old_flags );
257 if ( in_array( 'external', $flags ) ) {
258 $url = $row->old_text;
259 $parts = explode( '://', $url, 2 );
260 if ( !isset( $parts[1] ) || $parts[1] == '' ) {
261 return false;
262 }
263 $row->old_text = ExternalStore::fetchFromURL( $url );
264
265 }
266
267 if ( !in_array( 'object', $flags ) ) {
268 return false;
269 }
270
271 if ( in_array( 'gzip', $flags ) ) {
272 // This shouldn't happen, but a bug in the compress script
273 // may at times gzip-compress a HistoryBlob object row.
274 $obj = unserialize( gzinflate( $row->old_text ) );
275 } else {
276 $obj = unserialize( $row->old_text );
277 }
278
279 if ( !is_object( $obj ) ) {
280 // Correct for old double-serialization bug.
281 $obj = unserialize( $obj );
282 }
283
284 // Save this item for reference; if pulling many
285 // items in a row we'll likely use it again.
286 $obj->uncompress();
287 self::$blobCache = [ $this->mOldId => $obj ];
288 }
289
290 return $obj->getItem( $this->mHash );
291 }
292
293 /**
294 * Get the content hash
295 *
296 * @return string
297 */
298 function getHash() {
299 return $this->mHash;
300 }
301 }
302
303 /**
304 * To speed up conversion from 1.4 to 1.5 schema, text rows can refer to the
305 * leftover cur table as the backend. This avoids expensively copying hundreds
306 * of megabytes of data during the conversion downtime.
307 *
308 * Serialized HistoryBlobCurStub objects will be inserted into the text table
309 * on conversion if $wgLegacySchemaConversion is set to true.
310 */
311 class HistoryBlobCurStub {
312 /** @var int */
313 public $mCurId;
314
315 /**
316 * @param int $curid The cur_id pointed to
317 */
318 function __construct( $curid = 0 ) {
319 $this->mCurId = $curid;
320 }
321
322 /**
323 * Sets the location (cur_id) of the main object to which this object
324 * points
325 *
326 * @param int $id
327 */
328 function setLocation( $id ) {
329 $this->mCurId = $id;
330 }
331
332 /**
333 * @return string|bool
334 */
335 function getText() {
336 $dbr = wfGetDB( DB_REPLICA );
337 $row = $dbr->selectRow( 'cur', [ 'cur_text' ], [ 'cur_id' => $this->mCurId ] );
338 if ( !$row ) {
339 return false;
340 }
341 return $row->cur_text;
342 }
343 }
344
345 /**
346 * Diff-based history compression
347 * Requires xdiff 1.5+ and zlib
348 */
349 class DiffHistoryBlob implements HistoryBlob {
350 /** @var array Uncompressed item cache */
351 public $mItems = [];
352
353 /** @var int Total uncompressed size */
354 public $mSize = 0;
355
356 /**
357 * @var array Array of diffs. If a diff D from A to B is notated D = B - A,
358 * and Z is an empty string:
359 *
360 * { item[map[i]] - item[map[i-1]] where i > 0
361 * diff[i] = {
362 * { item[map[i]] - Z where i = 0
363 */
364 public $mDiffs;
365
366 /** @var array The diff map, see above */
367 public $mDiffMap;
368
369 /** @var int The key for getText()
370 */
371 public $mDefaultKey;
372
373 /** @var string Compressed storage */
374 public $mCompressed;
375
376 /** @var bool True if the object is locked against further writes */
377 public $mFrozen = false;
378
379 /**
380 * @var int The maximum uncompressed size before the object becomes sad
381 * Should be less than max_allowed_packet
382 */
383 public $mMaxSize = 10000000;
384
385 /** @var int The maximum number of text items before the object becomes sad */
386 public $mMaxCount = 100;
387
388 /** Constants from xdiff.h */
389 const XDL_BDOP_INS = 1;
390 const XDL_BDOP_CPY = 2;
391 const XDL_BDOP_INSB = 3;
392
393 function __construct() {
394 if ( !function_exists( 'gzdeflate' ) ) {
395 throw new MWException( "Need zlib support to read or write DiffHistoryBlob\n" );
396 }
397 }
398
399 /**
400 * @throws MWException
401 * @param string $text
402 * @return int
403 */
404 function addItem( $text ) {
405 if ( $this->mFrozen ) {
406 throw new MWException( __METHOD__ . ": Cannot add more items after sleep/wakeup" );
407 }
408
409 $this->mItems[] = $text;
410 $this->mSize += strlen( $text );
411 $this->mDiffs = null; // later
412 return count( $this->mItems ) - 1;
413 }
414
415 /**
416 * @param string $key
417 * @return string
418 */
419 function getItem( $key ) {
420 return $this->mItems[$key];
421 }
422
423 /**
424 * @param string $text
425 */
426 function setText( $text ) {
427 $this->mDefaultKey = $this->addItem( $text );
428 }
429
430 /**
431 * @return string
432 */
433 function getText() {
434 return $this->getItem( $this->mDefaultKey );
435 }
436
437 /**
438 * @throws MWException
439 */
440 function compress() {
441 if ( !function_exists( 'xdiff_string_rabdiff' ) ) {
442 throw new MWException( "Need xdiff 1.5+ support to write DiffHistoryBlob\n" );
443 }
444 if ( isset( $this->mDiffs ) ) {
445 // Already compressed
446 return;
447 }
448 if ( !count( $this->mItems ) ) {
449 // Empty
450 return;
451 }
452
453 // Create two diff sequences: one for main text and one for small text
454 $sequences = [
455 'small' => [
456 'tail' => '',
457 'diffs' => [],
458 'map' => [],
459 ],
460 'main' => [
461 'tail' => '',
462 'diffs' => [],
463 'map' => [],
464 ],
465 ];
466 $smallFactor = 0.5;
467
468 $mItemsCount = count( $this->mItems );
469 for ( $i = 0; $i < $mItemsCount; $i++ ) {
470 $text = $this->mItems[$i];
471 if ( $i == 0 ) {
472 $seqName = 'main';
473 } else {
474 $mainTail = $sequences['main']['tail'];
475 if ( strlen( $text ) < strlen( $mainTail ) * $smallFactor ) {
476 $seqName = 'small';
477 } else {
478 $seqName = 'main';
479 }
480 }
481 $seq =& $sequences[$seqName];
482 $tail = $seq['tail'];
483 $diff = $this->diff( $tail, $text );
484 $seq['diffs'][] = $diff;
485 $seq['map'][] = $i;
486 $seq['tail'] = $text;
487 }
488 unset( $seq ); // unlink dangerous alias
489
490 // Knit the sequences together
491 $tail = '';
492 $this->mDiffs = [];
493 $this->mDiffMap = [];
494 foreach ( $sequences as $seq ) {
495 if ( !count( $seq['diffs'] ) ) {
496 continue;
497 }
498 if ( $tail === '' ) {
499 $this->mDiffs[] = $seq['diffs'][0];
500 } else {
501 $head = $this->patch( '', $seq['diffs'][0] );
502 $this->mDiffs[] = $this->diff( $tail, $head );
503 }
504 $this->mDiffMap[] = $seq['map'][0];
505 $diffsCount = count( $seq['diffs'] );
506 for ( $i = 1; $i < $diffsCount; $i++ ) {
507 $this->mDiffs[] = $seq['diffs'][$i];
508 $this->mDiffMap[] = $seq['map'][$i];
509 }
510 $tail = $seq['tail'];
511 }
512 }
513
514 /**
515 * @param string $t1
516 * @param string $t2
517 * @return string
518 */
519 function diff( $t1, $t2 ) {
520 # Need to do a null concatenation with warnings off, due to bugs in the current version of xdiff
521 # "String is not zero-terminated"
522 MediaWiki\suppressWarnings();
523 $diff = xdiff_string_rabdiff( $t1, $t2 ) . '';
524 MediaWiki\restoreWarnings();
525 return $diff;
526 }
527
528 /**
529 * @param string $base
530 * @param string $diff
531 * @return bool|string
532 */
533 function patch( $base, $diff ) {
534 if ( function_exists( 'xdiff_string_bpatch' ) ) {
535 MediaWiki\suppressWarnings();
536 $text = xdiff_string_bpatch( $base, $diff ) . '';
537 MediaWiki\restoreWarnings();
538 return $text;
539 }
540
541 # Pure PHP implementation
542
543 $header = unpack( 'Vofp/Vcsize', substr( $diff, 0, 8 ) );
544
545 # Check the checksum if hash extension is available
546 $ofp = $this->xdiffAdler32( $base );
547 if ( $ofp !== false && $ofp !== substr( $diff, 0, 4 ) ) {
548 wfDebug( __METHOD__ . ": incorrect base checksum\n" );
549 return false;
550 }
551 if ( $header['csize'] != strlen( $base ) ) {
552 wfDebug( __METHOD__ . ": incorrect base length\n" );
553 return false;
554 }
555
556 $p = 8;
557 $out = '';
558 while ( $p < strlen( $diff ) ) {
559 $x = unpack( 'Cop', substr( $diff, $p, 1 ) );
560 $op = $x['op'];
561 ++$p;
562 switch ( $op ) {
563 case self::XDL_BDOP_INS:
564 $x = unpack( 'Csize', substr( $diff, $p, 1 ) );
565 $p++;
566 $out .= substr( $diff, $p, $x['size'] );
567 $p += $x['size'];
568 break;
569 case self::XDL_BDOP_INSB:
570 $x = unpack( 'Vcsize', substr( $diff, $p, 4 ) );
571 $p += 4;
572 $out .= substr( $diff, $p, $x['csize'] );
573 $p += $x['csize'];
574 break;
575 case self::XDL_BDOP_CPY:
576 $x = unpack( 'Voff/Vcsize', substr( $diff, $p, 8 ) );
577 $p += 8;
578 $out .= substr( $base, $x['off'], $x['csize'] );
579 break;
580 default:
581 wfDebug( __METHOD__ . ": invalid op\n" );
582 return false;
583 }
584 }
585 return $out;
586 }
587
588 /**
589 * Compute a binary "Adler-32" checksum as defined by LibXDiff, i.e. with
590 * the bytes backwards and initialised with 0 instead of 1. See T36428.
591 *
592 * @param string $s
593 * @return string|bool False if the hash extension is not available
594 */
595 function xdiffAdler32( $s ) {
596 if ( !function_exists( 'hash' ) ) {
597 return false;
598 }
599
600 static $init;
601 if ( $init === null ) {
602 $init = str_repeat( "\xf0", 205 ) . "\xee" . str_repeat( "\xf0", 67 ) . "\x02";
603 }
604
605 // The real Adler-32 checksum of $init is zero, so it initialises the
606 // state to zero, as it is at the start of LibXDiff's checksum
607 // algorithm. Appending the subject string then simulates LibXDiff.
608 return strrev( hash( 'adler32', $init . $s, true ) );
609 }
610
611 function uncompress() {
612 if ( !$this->mDiffs ) {
613 return;
614 }
615 $tail = '';
616 $mDiffsCount = count( $this->mDiffs );
617 for ( $diffKey = 0; $diffKey < $mDiffsCount; $diffKey++ ) {
618 $textKey = $this->mDiffMap[$diffKey];
619 $text = $this->patch( $tail, $this->mDiffs[$diffKey] );
620 $this->mItems[$textKey] = $text;
621 $tail = $text;
622 }
623 }
624
625 /**
626 * @return array
627 */
628 function __sleep() {
629 $this->compress();
630 if ( !count( $this->mItems ) ) {
631 // Empty object
632 $info = false;
633 } else {
634 // Take forward differences to improve the compression ratio for sequences
635 $map = '';
636 $prev = 0;
637 foreach ( $this->mDiffMap as $i ) {
638 if ( $map !== '' ) {
639 $map .= ',';
640 }
641 $map .= $i - $prev;
642 $prev = $i;
643 }
644 $info = [
645 'diffs' => $this->mDiffs,
646 'map' => $map
647 ];
648 }
649 if ( isset( $this->mDefaultKey ) ) {
650 $info['default'] = $this->mDefaultKey;
651 }
652 $this->mCompressed = gzdeflate( serialize( $info ) );
653 return [ 'mCompressed' ];
654 }
655
656 function __wakeup() {
657 // addItem() doesn't work if mItems is partially filled from mDiffs
658 $this->mFrozen = true;
659 $info = unserialize( gzinflate( $this->mCompressed ) );
660 unset( $this->mCompressed );
661
662 if ( !$info ) {
663 // Empty object
664 return;
665 }
666
667 if ( isset( $info['default'] ) ) {
668 $this->mDefaultKey = $info['default'];
669 }
670 $this->mDiffs = $info['diffs'];
671 if ( isset( $info['base'] ) ) {
672 // Old format
673 $this->mDiffMap = range( 0, count( $this->mDiffs ) - 1 );
674 array_unshift( $this->mDiffs,
675 pack( 'VVCV', 0, 0, self::XDL_BDOP_INSB, strlen( $info['base'] ) ) .
676 $info['base'] );
677 } else {
678 // New format
679 $map = explode( ',', $info['map'] );
680 $cur = 0;
681 $this->mDiffMap = [];
682 foreach ( $map as $i ) {
683 $cur += $i;
684 $this->mDiffMap[] = $cur;
685 }
686 }
687 $this->uncompress();
688 }
689
690 /**
691 * Helper function for compression jobs
692 * Returns true until the object is "full" and ready to be committed
693 *
694 * @return bool
695 */
696 function isHappy() {
697 return $this->mSize < $this->mMaxSize
698 && count( $this->mItems ) < $this->mMaxCount;
699 }
700
701 }
702
703 // @codingStandardsIgnoreStart
704 if ( false ) {
705 // Blobs generated by MediaWiki < 1.5 on PHP 4 were serialized with the
706 // class name coerced to lowercase. We can improve efficiency by adding
707 // autoload entries for the lowercase variants of these classes (T166759).
708 // The code below is never executed, but it is picked up by the AutoloadGenerator
709 // parser, which scans for class_alias() calls.
710 class_alias( 'ConcatenatedGzipHistoryBlob', 'concatenatedgziphistoryblob' );
711 class_alias( 'HistoryBlobCurStub', 'historyblobcurstub' );
712 class_alias( 'HistoryBlobStub', 'historyblobstub' );
713 }
714 // @codingStandardsIgnoreEnd