Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.95% covered (success)
95.95%
71 / 74
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocumentSizeLimiter
95.95% covered (success)
95.95%
71 / 74
60.00% covered (warning)
60.00%
3 / 5
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 estimateDataSize
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 resize
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 truncateField
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
13
 markWithTemplate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace CirrusSearch\BuildDocument;
4
5use CirrusSearch\Search\CirrusIndexField;
6use Elastica\Document;
7use Elastica\JSON;
8
9/**
10 * An approximate, incomplete and rather dangerous algorithm to reduce the size of a CirrusSearch
11 * document.
12 *
13 * This class is meant to reduce the size of abnormally large documents. What we can consider
14 * abnormally large is certainly prone to interpretation but this class was designed with numbers
15 * like 1Mb considered as extremely large. You should not expect this class to be byte precise
16 * and there is no guarantee that the resulting size after the operation will be below the expected
17 * max. There might be various reasons for this:
18 * - there are other fields than the ones listed above that take a lot of space
19 * - the expected size is so low that it does not even allow the json overhead to be present
20 *
21 * If the use-case is to ensure that the resulting json representation is below a size S you should
22 * definitely account for some overhead and ask this class to reduce the document to something smaller
23 * than S (i.e. S*0.9).
24 *
25 * Limiter heuristics are controlled by a profile that supports the following criteria:
26 * - max_size (int): the target maximum size of the document (when serialized as json)
27 * - field_types (array<string, string>): field name as key, the type of field (text or keyword) as value
28 * - max_field_size (array<string, int>): field name as key, max size as value, truncate these fields
29 * to the appropriate size
30 * - fields (array<string, int>): field name as key, min size as value, truncate these fields up to this
31 * minimal size as long as the document size is above max_size
32 * - markup_template (string): mark the document with this template if it was oversize.
33 *
34 * Text fields are truncated using mb_strcut, if the string is part of an array and it becomes empty
35 * after the truncation it's removed from the array, if the string is a "keyword" (non tokenized
36 * field) it's not truncated and simply removed from its array.
37 *
38 * If an array is mixing string and non-string data it's ignored.
39 */
40class DocumentSizeLimiter {
41    public const MANDATORY_REDUCTION_BUCKET = "mandatory_reduction";
42    public const OVERSIZE_REDUCTION_REDUCTION_BUCKET = "oversize_reduction";
43    public const HINT_DOC_SIZE_LIMITER_STATS = 'DocumentSizeLimiter_stats';
44
45    /** @var int */
46    private $maxDocSize;
47    /** @var int */
48    private $docLength;
49    /** @var Document */
50    private $document;
51    /** @var string[] */
52    private $fieldTypes;
53    /** @var int[] list of max field length */
54    private $maxFieldSize;
55    /** @var int[] list of fields to truncate when the doc is oversize, value is the min length to keep */
56    private $fields;
57    /** @var array<string,array<string,int>> */
58    private $stats;
59    /** @var mixed|null */
60    private $markupTemplate;
61    /** @var int the actual max size a truncated document can (takes into account the markup template that has to be added) */
62    private $actualMaxDocSize;
63
64    public function __construct( array $profile ) {
65        $this->maxDocSize = $profile['max_size'] ?? PHP_INT_MAX;
66        $this->fieldTypes = $profile['field_types'] ?? [];
67        $this->maxFieldSize = $profile["max_field_size"] ?? [];
68        $this->fields = $profile["fields"] ?? [];
69        $this->markupTemplate = $profile["markup_template"] ?? null;
70        $this->actualMaxDocSize = $this->maxDocSize;
71        if ( $this->markupTemplate !== null ) {
72            $this->actualMaxDocSize -= strlen( $this->markupTemplate ) + 3; // 3 is 2 " and a comma
73        }
74    }
75
76    public static function estimateDataSize( Document $document ): int {
77        try {
78            return strlen( JSON::stringify( $document->getData(), \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE ) );
79        } catch ( \JsonException ) {
80            // Ignore, consider this of length 0, process is likely to fail at later point
81        }
82        return 0;
83    }
84
85    /**
86     * Truncate some textual data from the input Document.
87     * @param Document $document
88     * @return array some statistics about the process.
89     */
90    public function resize( Document $document ): array {
91        $this->stats = [];
92        $this->document = $document;
93        $originalDocLength = self::estimateDataSize( $document );
94        $this->docLength = $originalDocLength;
95        // first pass to force some fields
96        foreach ( $this->maxFieldSize as $field => $len ) {
97            $this->truncateField( $field, ( $this->fieldTypes[$field] ?? "text" ) === "keyword",
98                $len, 0, self::MANDATORY_REDUCTION_BUCKET );
99        }
100
101        // second pass applied only if the doc is oversize
102        if ( $this->docLength > $this->maxDocSize ) {
103            foreach ( $this->fields as $field => $len ) {
104                if ( $this->docLength <= $this->actualMaxDocSize ) {
105                    break;
106                }
107                $this->truncateField( $field, ( $this->fieldTypes[$field] ?? "text" ) === "keyword",
108                    $len, $this->actualMaxDocSize, self::OVERSIZE_REDUCTION_REDUCTION_BUCKET );
109            }
110        }
111        /** @phan-suppress-next-line PhanRedundantCondition */
112        if ( $this->markupTemplate != null && !empty( $this->stats[self::OVERSIZE_REDUCTION_REDUCTION_BUCKET] ) ) {
113            $this->markWithTemplate( $document );
114        }
115        $this->stats["document"] = [
116            "original_length" => $originalDocLength,
117            "new_length" => $this->docLength,
118        ];
119        CirrusIndexField::setHint( $document, self::HINT_DOC_SIZE_LIMITER_STATS, $this->stats );
120        return $this->stats;
121    }
122
123    private function truncateField( string $field, bool $keyword, int $minFieldLength, int $maxDocSize, string $statBucket ): void {
124        if ( !$this->document->has( $field ) ) {
125            return;
126        }
127        $fieldData = $this->document->get( $field );
128        $plainString = false;
129
130        // If the field is a plain string but is marked as a keyword we prefer to not touch it.
131        // It is probable that such fields are not of variable length (IDs, mimetypes) and thus
132        // it would make little to have a profile that tries to truncate those. But out of caution
133        // we simply skip those.
134        if ( is_string( $fieldData ) && !$keyword ) {
135            // wrap and plain string into an array to reuse the same loop as string[] fields.
136            $fieldData = [ $fieldData ];
137            $plainString = true;
138        }
139
140        if ( !is_array( $fieldData ) ||
141            // not messing-up with mixed-types
142            array_any( $fieldData, static fn ( $val ) => !is_string( $val ) )
143        ) {
144            return;
145        }
146
147        $fieldLen = array_reduce( $fieldData, static fn ( $sum, $str ) => $sum + strlen( $str ), 0 );
148        $sizeReduction = 0;
149        // Since we generally truncate the end of a text we also remove array elements from the end.
150        for ( $index = count( $fieldData ); $index--; ) {
151            $remainingFieldLen = $fieldLen - $sizeReduction;
152            $maxSizeToRemove = $this->docLength - $sizeReduction - $maxDocSize;
153            if ( $remainingFieldLen <= $minFieldLength ||
154                $maxSizeToRemove <= 0 ||
155                $remainingFieldLen <= 0
156            ) {
157                break;
158            }
159            $data = &$fieldData[$index];
160            $len = strlen( $data );
161            if ( $keyword ) {
162                $sizeReduction += strlen( $data );
163                unset( $fieldData[$index] );
164            } else {
165                $removableLen = $remainingFieldLen - $minFieldLength;
166
167                $newLen = $len - max( min( $maxSizeToRemove, $len, $removableLen ), 0 );
168                $data = mb_strcut( $data, 0, $newLen );
169                $sizeReduction += $len - strlen( $data );
170                if ( $data === "" ) {
171                    unset( $fieldData[$index] );
172                }
173            }
174        }
175        $this->docLength -= $sizeReduction;
176        $fieldData = array_values( $fieldData );
177        if ( $plainString ) {
178            $fieldData = array_pop( $fieldData ) ?? ""; // prefers empty string over null
179        }
180        $this->document->set( $field, $fieldData );
181        $this->stats[$statBucket][$field] = $sizeReduction;
182    }
183
184    private function markWithTemplate( Document $document ) {
185        $templates = [];
186        if ( $document->has( "template" ) ) {
187            $templates = $document->get( "template" );
188        }
189        // add this markup to the main NS to avoid pulling Title and the ns text service
190        // this will be searchable via hastemplate::the_markup_template
191        $templates[] = $this->markupTemplate;
192        $this->docLength += strlen( $this->markupTemplate ) + 2 + ( count( $templates ) > 1 ? 1 : 0 );
193        $document->set( "template", $templates );
194    }
195}