Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.84% covered (warning)
63.84%
203 / 318
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
DataSender
63.84% covered (warning)
63.84%
203 / 318
25.00% covered (danger)
25.00%
3 / 12
294.17
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
1
 sendWeightedTagsUpdate
69.81% covered (warning)
69.81%
37 / 53
0.00% covered (danger)
0.00%
0 / 1
9.76
 sendData
68.69% covered (warning)
68.69%
68 / 99
0.00% covered (danger)
0.00%
0 / 1
34.54
 reportUpdateMetrics
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 sendDeletes
67.65% covered (warning)
67.65%
23 / 34
0.00% covered (danger)
0.00%
0 / 1
5.85
 sendOtherIndexUpdates
75.00% covered (warning)
75.00%
33 / 44
0.00% covered (danger)
0.00%
0 / 1
7.77
 decideRequiredSetAction
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 bulkResponseExceptionIsJustDocumentMissing
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 newLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 docToSuperDetectNoopScript
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 retryOnConflict
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 reportDocSize
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
1<?php
2
3namespace CirrusSearch;
4
5use CirrusSearch\BuildDocument\BuildDocument;
6use CirrusSearch\BuildDocument\BuildDocumentException;
7use CirrusSearch\BuildDocument\DocumentSizeLimiter;
8use CirrusSearch\Extra\MultiList\MultiListBuilder;
9use CirrusSearch\Profile\SearchProfileService;
10use CirrusSearch\Search\CirrusIndexField;
11use CirrusSearch\Search\WeightedTagsHooks;
12use Elastica\Bulk\Action\AbstractDocument;
13use Elastica\Document;
14use Elastica\Exception\Bulk\ResponseException;
15use Elastica\Exception\RuntimeException;
16use Elastica\JSON;
17use Elastica\Response;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Status\Status;
21use MediaWiki\Title\Title;
22use Wikimedia\Assert\Assert;
23use Wikimedia\Rdbms\IDBAccessObject;
24use Wikimedia\Stats\StatsFactory;
25
26/**
27 * Handles non-maintenance write operations to the elastic search cluster.
28 *
29 * @license GPL-2.0-or-later
30 */
31class DataSender extends ElasticsearchIntermediary {
32
33    /** @var \Psr\Log\LoggerInterface */
34    private $log;
35
36    /** @var \Psr\Log\LoggerInterface */
37    private $failedLog;
38
39    /**
40     * @var string
41     */
42    private $indexBaseName;
43
44    /**
45     * @var SearchConfig
46     */
47    private $searchConfig;
48
49    private StatsFactory $stats;
50    /**
51     * @var DocumentSizeLimiter
52     */
53    private $docSizeLimiter;
54
55    /**
56     * @param Connection $conn
57     * @param SearchConfig $config
58     * @param StatsFactory|null $stats A StatsFactory (already prefixed with the right component)
59     * @param DocumentSizeLimiter|null $docSizeLimiter
60     */
61    public function __construct(
62        Connection $conn,
63        SearchConfig $config,
64        ?StatsFactory $stats = null,
65        ?DocumentSizeLimiter $docSizeLimiter = null
66    ) {
67        parent::__construct( $conn, null, 0 );
68        $this->stats = $stats ?? Util::getStatsFactory();
69        $this->log = LoggerFactory::getInstance( LogChannel::DEFAULT );
70        $this->failedLog = LoggerFactory::getInstance( LogChannel::CHANGE_FAILED );
71        $this->indexBaseName = $config->get( SearchConfig::INDEX_BASE_NAME );
72        $this->searchConfig = $config;
73        $this->docSizeLimiter = $docSizeLimiter ?? new DocumentSizeLimiter(
74            $config->getProfileService()->loadProfile( SearchProfileService::DOCUMENT_SIZE_LIMITER ) );
75    }
76
77    public function sendWeightedTagsUpdate(
78        string $indexSuffix,
79        string $tagPrefix,
80        array $tagWeights,
81        int $batchSize = 30
82    ): Status {
83        $client = $this->connection->getClient();
84        $status = Status::newGood();
85        $pageIndex = $this->connection->getIndex( $this->indexBaseName, $indexSuffix );
86        foreach ( array_chunk( array_keys( $tagWeights ), $batchSize ) as $docIdsChunk ) {
87            $bulk = new \Elastica\Bulk( $client );
88            $bulk->setIndex( $pageIndex );
89            foreach ( $docIdsChunk as $docId ) {
90                $docTags = MultiListBuilder::buildWeightedTags(
91                    $tagPrefix,
92                    $tagWeights[$docId],
93                );
94                $script = new \Elastica\Script\Script( 'super_detect_noop', [
95                    'source' => [
96                        WeightedTagsHooks::FIELD_NAME => array_map( static fn ( $docTag ) => (string)$docTag,
97                            $docTags )
98                    ],
99                    'handlers' => [ WeightedTagsHooks::FIELD_NAME => CirrusIndexField::MULTILIST_HANDLER ],
100                ], 'super_detect_noop' );
101                $script->setId( $docId );
102                $bulk->addScript( $script, 'update' );
103            }
104
105            if ( !$bulk->getActions() ) {
106                continue;
107            }
108
109            // Execute the bulk update
110            $exception = null;
111            try {
112                $this->start(
113                    new BulkUpdateRequestLog(
114                        $this->connection->getClient(),
115                        'updating {numBulk} documents',
116                        'send_data_reset_weighted_tags',
117                        [
118                            'numBulk' => count( $docIdsChunk ),
119                            'index' => $pageIndex->getName()
120                        ]
121                    )
122                );
123                $bulk->send();
124            } catch ( ResponseException $e ) {
125                if ( !$this->bulkResponseExceptionIsJustDocumentMissing( $e ) ) {
126                    $exception = $e;
127                }
128            } catch ( \Elastica\Exception\ExceptionInterface $e ) {
129                $exception = $e;
130            }
131            if ( $exception === null ) {
132                $this->success();
133            } else {
134                $this->failure( $exception );
135                $this->failedLog->warning(
136                    "Update weighted tag {weightedTagFieldName} for {weightedTagPrefix} in articles: {docIds}", [
137                        'exception' => $exception,
138                        'weightedTagFieldName' => WeightedTagsHooks::FIELD_NAME,
139                        'weightedTagPrefix' => $tagPrefix,
140                        'weightedTagWeight' => var_export( $tagWeights, true ),
141                        'docIds' => implode( ',', array_keys( $tagWeights ) )
142                    ]
143                );
144            }
145        }
146
147        return $status;
148    }
149
150    /**
151     * @param string $indexSuffix suffix of index to which to send $documents
152     * @param \Elastica\Document[] $documents documents to send
153     * @return Status
154     */
155    public function sendData( $indexSuffix, array $documents ) {
156        if ( !$documents ) {
157            return Status::newGood();
158        }
159
160        // Copy the docs so that modifications made in this method are not propagated up to the caller
161        $docsCopy = [];
162        foreach ( $documents as $doc ) {
163            $docsCopy[] = clone $doc;
164        }
165        $documents = $docsCopy;
166
167        // Perform final stage of document building. This only
168        // applies to `page` documents, docs built by something
169        // other than BuildDocument will pass through unchanged.
170        $services = MediaWikiServices::getInstance();
171        $builder = new BuildDocument(
172            $this->connection,
173            $services->getConnectionProvider()->getReplicaDatabase(),
174            $services->getRevisionStore(),
175            $services->getBacklinkCacheFactory(),
176            $this->docSizeLimiter,
177            $services->getTitleFormatter(),
178            $services->getWikiPageFactory(),
179            $services->getTitleFactory()
180        );
181        try {
182            foreach ( $documents as $i => $doc ) {
183                if ( !$builder->finalize( $doc ) ) {
184                    // Something has changed while this was hanging out in the job
185                    // queue and should no longer be written to elastic.
186                    unset( $documents[$i] );
187                }
188                $this->reportDocSize( $doc );
189            }
190        } catch ( BuildDocumentException $be ) {
191            $this->failedLog->warning(
192                'Failed to update documents',
193                [ 'exception' => $be ]
194            );
195            return Status::newFatal( 'cirrussearch-failed-build-document' );
196        }
197
198        if ( !$documents ) {
199            // All documents noop'd
200            return Status::newGood();
201        }
202
203        /**
204         * Transform the finalized documents into noop scripts if possible
205         * to reduce update load.
206         */
207        if ( $this->searchConfig->getElement( CirrusConfigNames::WikimediaExtraPlugin, 'super_detect_noop' ) ) {
208            foreach ( $documents as $i => $doc ) {
209                // BC Check for jobs that used to contain Document|Script
210                if ( $doc instanceof \Elastica\Document ) {
211                    $documents[$i] = $this->docToSuperDetectNoopScript( $doc );
212                }
213            }
214        }
215
216        foreach ( $documents as $doc ) {
217            $doc->setRetryOnConflict( $this->retryOnConflict() );
218            // Hints need to be retained until after finalizing
219            // the documents and building the noop scripts.
220            CirrusIndexField::resetHints( $doc );
221        }
222
223        $exception = null;
224        $responseSet = null;
225        $justDocumentMissing = false;
226        try {
227            $pageIndex = $this->connection->getIndex( $this->indexBaseName, $indexSuffix );
228
229            $this->start( new BulkUpdateRequestLog(
230                $this->connection->getClient(),
231                'sending {numBulk} documents to the {index} index(s)',
232                'send_data_write',
233                [ 'numBulk' => count( $documents ), 'index' => $pageIndex->getName() ]
234            ) );
235            $bulk = new \Elastica\Bulk( $this->connection->getClient() );
236            $bulk->setShardTimeout( $this->searchConfig->get( CirrusConfigNames::UpdateShardTimeout ) );
237            $bulk->setIndex( $pageIndex );
238            if ( $this->searchConfig->getElement( CirrusConfigNames::ElasticQuirks, 'retry_on_conflict' ) ) {
239                $actions = [];
240                foreach ( $documents as $doc ) {
241                    $action = AbstractDocument::create( $doc, 'update' );
242                    $metadata = $action->getMetadata();
243                    // Rename deprecated _retry_on_conflict
244                    // TODO: fix upstream in Elastica.
245                    if ( isset( $metadata['_retry_on_conflict'] ) ) {
246                        $metadata['retry_on_conflict'] = $metadata['_retry_on_conflict'];
247                        unset( $metadata['_retry_on_conflict'] );
248                        $action->setMetadata( $metadata );
249                    }
250                    $actions[] = $action;
251                }
252
253                $bulk->addActions( $actions );
254            } else {
255                $bulk->addData( $documents, 'update' );
256            }
257            $responseSet = $bulk->send();
258        } catch ( ResponseException $e ) {
259            $justDocumentMissing = $this->bulkResponseExceptionIsJustDocumentMissing( $e,
260                function ( $docId ) use ( $indexSuffix ) {
261                    $this->log->info(
262                        "Updating a page that doesn't yet exist in Elasticsearch: {docId}",
263                        [ 'docId' => $docId, 'indexSuffix' => $indexSuffix ]
264                    );
265                }
266            );
267            $exception = $e;
268        } catch ( \Elastica\Exception\ExceptionInterface $e ) {
269            $exception = $e;
270        }
271
272        if ( $justDocumentMissing ) {
273            // wa have a failure but this is just docs that are missing in the index
274            // missing docs are logged above
275            $this->success();
276            return Status::newGood();
277        }
278        // check if the response is valid by making sure that it has bulk responses
279        if ( $responseSet !== null && count( $responseSet->getBulkResponses() ) > 0 ) {
280            $this->success();
281            $this->reportUpdateMetrics( $responseSet, $indexSuffix, count( $documents ) );
282            return Status::newGood();
283        }
284        // Everything else should be a failure.
285        if ( $exception === null ) {
286            // Elastica failed to identify the error, reason is that the Elastica Bulk\Response
287            // does identify errors only in individual responses if the request fails without
288            // getting a formal elastic response Bulk\Response->isOk might remain true
289            // So here we construct the ResponseException that should have been built and thrown
290            // by Elastica
291            $lastRequest = $this->connection->getClient()->getLastRequest();
292            if ( $lastRequest !== null ) {
293                $exception = new \Elastica\Exception\ResponseException( $lastRequest,
294                    new Response( $responseSet->getData() ) );
295            } else {
296                $exception = new RuntimeException( "Unknown error in bulk request (Client::getLastRequest() is null)" );
297            }
298        }
299        $this->failure( $exception );
300        $documentIds = array_map( static function ( $d ) {
301            return (string)( $d->getId() );
302        }, $documents );
303        $this->failedLog->warning(
304            'Failed to update documents {docId}',
305            [
306                'docId' => implode( ', ', $documentIds ),
307                'exception' => $exception
308            ]
309        );
310        return Status::newFatal( 'cirrussearch-failed-send-data' );
311    }
312
313    /**
314     * @param \Elastica\Bulk\ResponseSet $responseSet
315     * @param string $indexSuffix
316     * @param int $sent
317     */
318    private function reportUpdateMetrics(
319        \Elastica\Bulk\ResponseSet $responseSet, $indexSuffix, $sent
320    ) {
321        $updateStats = [
322            'sent' => $sent,
323        ];
324        $allowedOps = [ 'created', 'updated', 'noop' ];
325        foreach ( $responseSet->getBulkResponses() as $bulk ) {
326            $opRes = 'unknown';
327            if ( $bulk instanceof \Elastica\Bulk\Response ) {
328                if ( isset( $bulk->getData()['result'] )
329                    && in_array( $bulk->getData()['result'], $allowedOps )
330                ) {
331                    $opRes = $bulk->getData()['result'];
332                }
333            }
334            if ( isset( $updateStats[$opRes] ) ) {
335                $updateStats[$opRes]++;
336            } else {
337                $updateStats[$opRes] = 1;
338            }
339        }
340        $cluster = $this->connection->getClusterName();
341        $metricsPrefix = "CirrusSearch.$cluster.updates";
342        foreach ( $updateStats as $what => $num ) {
343            $this->stats->getCounter( "update_total" )
344                ->setLabel( "status", $what )
345                ->setLabel( "search_cluster", $cluster )
346                ->setLabel( "index_name", $this->indexBaseName )
347                ->setLabel( "index_suffix", $indexSuffix )
348                ->incrementBy( $num );
349        }
350    }
351
352    /**
353     * Send delete requests to Elasticsearch.
354     *
355     * @param string[] $docIds elasticsearch document ids to delete
356     * @param string|null $indexSuffix index from which to delete.  null means all.
357     * @return Status
358     */
359    public function sendDeletes( $docIds, $indexSuffix = null ) {
360        if ( $indexSuffix === null ) {
361            $indexes = $this->connection->getAllIndexSuffixes( Connection::PAGE_DOC_TYPE );
362        } else {
363            $indexes = [ $indexSuffix ];
364        }
365
366        $idCount = count( $docIds );
367        if ( $idCount !== 0 ) {
368            try {
369                foreach ( $indexes as $indexSuffix ) {
370                    $this->startNewLog(
371                        'deleting {numIds} from {indexSuffix}',
372                        'send_deletes', [
373                            'numIds' => $idCount,
374                            'indexSuffix' => $indexSuffix,
375                        ]
376                    );
377                    $this->connection
378                        ->getIndex( $this->indexBaseName, $indexSuffix )
379                        ->deleteDocuments(
380                            array_map(
381                                static function ( $id ) {
382                                    return new Document( $id );
383                                }, $docIds
384                            )
385                        );
386                    $this->success();
387                }
388            } catch ( \Elastica\Exception\ExceptionInterface $e ) {
389                $this->failure( $e );
390                $this->failedLog->warning(
391                    'Failed to delete documents: {docId}',
392                    [
393                        'docId' => implode( ', ', $docIds ),
394                        'exception' => $e,
395                    ]
396                );
397                return Status::newFatal( 'cirrussearch-failed-send-deletes' );
398            }
399        }
400
401        return Status::newGood();
402    }
403
404    /**
405     * @param string $localSite The wikiId to add/remove from local_sites_with_dupe
406     * @param string $indexName The name of the index to perform updates to
407     * @param array[] $otherActions A list of arrays each containing the id within elasticsearch
408     *   ('docId') and the article namespace ('ns') and DB key ('dbKey') at the within $localSite
409     * @param int $batchSize number of docs to update in a single bulk
410     * @return Status
411     */
412    public function sendOtherIndexUpdates( $localSite, $indexName, array $otherActions, $batchSize = 30 ) {
413        $client = $this->connection->getClient();
414        $status = Status::newGood();
415        foreach ( array_chunk( $otherActions, $batchSize ) as $updates ) {
416            '@phan-var array[] $updates';
417            $bulk = new \Elastica\Bulk( $client );
418            $titles = [];
419            foreach ( $updates as $update ) {
420                $title = Title::makeTitle( $update['ns'], $update['dbKey'] );
421                $action = $this->decideRequiredSetAction( $title );
422                $script = new \Elastica\Script\Script(
423                    'super_detect_noop',
424                    [
425                        'source' => [
426                            'local_sites_with_dupe' => [ $action => $localSite ],
427                        ],
428                        'handlers' => [ 'local_sites_with_dupe' => 'set' ],
429                    ],
430                    'super_detect_noop'
431                );
432                $script->setId( $update['docId'] );
433                $script->setParam( '_index', $indexName );
434                $bulk->addScript( $script, 'update' );
435                $titles[] = $title;
436            }
437
438            // Execute the bulk update
439            $exception = null;
440            try {
441                $this->start( new BulkUpdateRequestLog(
442                    $this->connection->getClient(),
443                    'updating {numBulk} documents in other indexes',
444                    'send_data_other_idx_write',
445                    [ 'numBulk' => count( $updates ), 'index' => $indexName ]
446                ) );
447                $bulk->send();
448            } catch ( ResponseException $e ) {
449                if ( !$this->bulkResponseExceptionIsJustDocumentMissing( $e ) ) {
450                    $exception = $e;
451                }
452            } catch ( \Elastica\Exception\ExceptionInterface $e ) {
453                $exception = $e;
454            }
455            if ( $exception === null ) {
456                $this->success();
457            } else {
458                $this->failure( $exception );
459                $this->failedLog->warning(
460                    "OtherIndex update for articles: {titleStr}",
461                    [ 'exception' => $exception, 'titleStr' => implode( ',', $titles ) ]
462                );
463                $status->error( 'cirrussearch-failed-update-otherindex' );
464            }
465        }
466
467        return $status;
468    }
469
470    /**
471     * Decide what action is required to the other index to make it up
472     * to data with the current wiki state. This will always check against
473     * the master database.
474     *
475     * @param Title $title The title to decide the action for
476     * @return string The set action to be performed. Either 'add' or 'remove'
477     */
478    protected function decideRequiredSetAction( Title $title ) {
479        $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
480        $page->loadPageData( IDBAccessObject::READ_LATEST );
481        if ( $page->exists() ) {
482            return 'add';
483        } else {
484            return 'remove';
485        }
486    }
487
488    /**
489     * Check if $exception is a bulk response exception that just contains
490     * document is missing failures.
491     *
492     * @param ResponseException $exception exception to check
493     * @param callable|null $logCallback Callback in which to do some logging.
494     *   Callback will be passed the id of the missing document.
495     * @return bool
496     */
497    protected function bulkResponseExceptionIsJustDocumentMissing(
498        ResponseException $exception, $logCallback = null
499    ) {
500        $justDocumentMissing = true;
501        foreach ( $exception->getResponseSet()->getBulkResponses() as $bulkResponse ) {
502            if ( !$bulkResponse->hasError() ) {
503                continue;
504            }
505
506            $error = $bulkResponse->getFullError();
507            if ( is_string( $error ) ) {
508                // es 1.7 cluster
509                $message = $bulkResponse->getError();
510                if ( strpos( $message, 'DocumentMissingException' ) === false ) {
511                    $justDocumentMissing = false;
512                    continue;
513                }
514            } else {
515                // es 2.x cluster
516                if ( $error !== null && $error['type'] !== 'document_missing_exception' ) {
517                    $justDocumentMissing = false;
518                    continue;
519                }
520            }
521
522            if ( $logCallback ) {
523                // This is generally not an error but we should
524                // log it to see how many we get
525                $action = $bulkResponse->getAction();
526                $docId = 'missing';
527                if ( $action instanceof \Elastica\Bulk\Action\AbstractDocument ) {
528                    $docId = $action->getData()->getId();
529                }
530                $logCallback( $docId );
531            }
532        }
533        return $justDocumentMissing;
534    }
535
536    /**
537     * @param string $description
538     * @param string $queryType
539     * @param string[] $extra
540     * @return SearchRequestLog
541     */
542    protected function newLog( $description, $queryType, array $extra = [] ) {
543        return new SearchRequestLog(
544            $this->connection->getClient(),
545            $description,
546            $queryType,
547            $extra
548        );
549    }
550
551    /**
552     * Converts a document into a call to super_detect_noop from the wikimedia-extra plugin.
553     * @param \Elastica\Document $doc
554     * @return \Elastica\Script\Script
555     * @internal made public for testing purposes
556     */
557    public function docToSuperDetectNoopScript( \Elastica\Document $doc ) {
558        $handlers = CirrusIndexField::getHint( $doc, CirrusIndexField::NOOP_HINT );
559        $params = array_diff_key( $doc->getParams(), [ CirrusIndexField::DOC_HINT_PARAM => 1 ] );
560
561        $params['source'] = $doc->getData();
562
563        if ( $handlers ) {
564            Assert::precondition( is_array( $handlers ), "Noop hints must be an array" );
565            $params['handlers'] = $handlers;
566        } else {
567            $params['handlers'] = [];
568        }
569        $extraHandlers = $this->searchConfig->getElement(
570            CirrusConfigNames::WikimediaExtraPlugin, 'super_detect_noop_handlers' );
571        if ( is_array( $extraHandlers ) ) {
572            $params['handlers'] += $extraHandlers;
573        }
574
575        if ( $params['handlers'] === [] ) {
576            // The noop script only supports Map but an empty array
577            // may be transformed to [] instead of {} when serialized to json
578            // causing class cast exception failures
579            $params['handlers'] = (object)[];
580        }
581        $script = new \Elastica\Script\Script( 'super_detect_noop', $params, 'super_detect_noop' );
582        if ( $doc->getDocAsUpsert() ) {
583            CirrusIndexField::resetHints( $doc );
584            $script->setUpsert( $doc );
585        }
586
587        return $script;
588    }
589
590    /**
591     * @return int Number of times to instruct Elasticsearch to retry updates that fail on
592     *  version conflicts.
593     */
594    private function retryOnConflict(): int {
595        return $this->searchConfig->get(
596            CirrusConfigNames::UpdateConflictRetryCount );
597    }
598
599    private function reportDocSize( Document $doc ): void {
600        $cluster = $this->connection->getClusterName();
601        try {
602            // Use the same JSON output that Elastica uses, it might not be the options MW uses
603            // to populate event-gate (esp. regarding escaping UTF-8) but hopefully it's close
604            // to what we will be using.
605            $len = strlen( JSON::stringify( $doc->getData(), \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES ) );
606            // Use a timing stat as we'd like to have percentiles calculated (possibly use T348796 once available)
607            // note that prior to switching to prometheus we used to have min and max, if that's proven to be still useful
608            // to track abnormally large docs we might consider another approach (log a warning?)
609            $this->stats->getTiming( "update_doc_size_kb" )
610                ->setLabel( "search_cluster", $cluster )
611                ->observe( $len );
612
613        } catch ( \JsonException $e ) {
614            $this->log->warning( "Cannot estimate CirrusSearch doc size", [ "exception" => $e ] );
615        }
616    }
617
618}