File indexing completed on 2024-11-10 04:40:53

0001 /*
0002     SPDX-FileCopyrightText: 2010 Volker Krause <vkrause@kde.org>
0003     SPDX-FileCopyrightText: 2013 Daniel Vrátil <dvratil@redhat.com>
0004 
0005     SPDX-License-Identifier: LGPL-2.0-or-later
0006 */
0007 
0008 #include "searchmanager.h"
0009 #include "abstractsearchplugin.h"
0010 #include "akonadiserver_search_debug.h"
0011 
0012 #include "agentsearchengine.h"
0013 #include "akonadi.h"
0014 #include "handler/searchhelper.h"
0015 #include "notificationmanager.h"
0016 #include "searchrequest.h"
0017 #include "searchtaskmanager.h"
0018 #include "storage/datastore.h"
0019 #include "storage/querybuilder.h"
0020 #include "storage/selectquerybuilder.h"
0021 #include "storage/transaction.h"
0022 
0023 #include "private/protocol_p.h"
0024 
0025 #include <QDBusConnection>
0026 #include <QDir>
0027 #include <QPluginLoader>
0028 #include <QTimer>
0029 
0030 #include <memory>
0031 
0032 Q_DECLARE_METATYPE(Akonadi::Server::NotificationCollector *)
0033 
0034 using namespace Akonadi;
0035 using namespace Akonadi::Server;
0036 
0037 Q_DECLARE_METATYPE(Collection)
0038 
0039 SearchManager::SearchManager(const QStringList &searchEngines, SearchTaskManager &agentSearchManager)
0040     : AkThread(QStringLiteral("SearchManager"), AkThread::ManualStart, QThread::InheritPriority)
0041     , mAgentSearchManager(agentSearchManager)
0042     , mEngineNames(searchEngines)
0043     , mSearchUpdateTimer(nullptr)
0044 {
0045     qRegisterMetaType<Collection>();
0046 
0047     // We load search plugins (as in QLibrary::load()) in the main thread so that
0048     // static initialization happens in the QApplication thread
0049     loadSearchPlugins();
0050 
0051     // Register to DBus on the main thread connection - otherwise we don't appear
0052     // on the service.
0053     QDBusConnection conn = QDBusConnection::sessionBus();
0054     conn.registerObject(QStringLiteral("/SearchManager"), this, QDBusConnection::ExportAllSlots);
0055 
0056     // Delay-call init()
0057     startThread();
0058 }
0059 
0060 void SearchManager::init()
0061 {
0062     AkThread::init();
0063 
0064     mEngines.reserve(mEngineNames.size());
0065     for (const QString &engineName : std::as_const(mEngineNames)) {
0066         if (engineName == QLatin1StringView("Agent")) {
0067             mEngines.append(new AgentSearchEngine);
0068         } else {
0069             qCCritical(AKONADISERVER_SEARCH_LOG) << "Unknown search engine type: " << engineName;
0070         }
0071     }
0072 
0073     initSearchPlugins();
0074 
0075     // The timer will tick 15 seconds after last change notification. If a new notification
0076     // is delivered in the meantime, the timer is reset
0077     mSearchUpdateTimer = new QTimer(this);
0078     mSearchUpdateTimer->setInterval(15 * 1000);
0079     mSearchUpdateTimer->setSingleShot(true);
0080     connect(mSearchUpdateTimer, &QTimer::timeout, this, &SearchManager::searchUpdateTimeout);
0081 }
0082 
0083 void SearchManager::quit()
0084 {
0085     QDBusConnection conn = QDBusConnection::sessionBus();
0086     conn.unregisterObject(QStringLiteral("/SearchManager"), QDBusConnection::UnregisterTree);
0087     conn.disconnectFromBus(conn.name());
0088 
0089     // Make sure all children are deleted within context of this thread
0090     qDeleteAll(children());
0091 
0092     qDeleteAll(mEngines);
0093     qDeleteAll(mPlugins);
0094     /*
0095      * FIXME: Unloading plugin messes up some global statics from client libs
0096      * and causes crash on Akonadi shutdown (below main). Keeping the plugins
0097      * loaded is not really a big issue as this is only invoked on server shutdown
0098      * anyway, so we are not leaking any memory.
0099     Q_FOREACH (QPluginLoader *loader, mPluginLoaders) {
0100         loader->unload();
0101         delete loader;
0102     }
0103     */
0104 
0105     AkThread::quit();
0106 }
0107 
0108 SearchManager::~SearchManager()
0109 {
0110     quitThread();
0111 }
0112 
0113 void SearchManager::registerInstance(const QString &id)
0114 {
0115     mAgentSearchManager.registerInstance(id);
0116 }
0117 
0118 void SearchManager::unregisterInstance(const QString &id)
0119 {
0120     mAgentSearchManager.unregisterInstance(id);
0121 }
0122 
0123 QList<AbstractSearchPlugin *> SearchManager::searchPlugins() const
0124 {
0125     return mPlugins;
0126 }
0127 
0128 void SearchManager::loadSearchPlugins()
0129 {
0130     QStringList loadedPlugins;
0131     const QString pluginOverride = QString::fromLatin1(qgetenv("AKONADI_OVERRIDE_SEARCHPLUGIN"));
0132     if (!pluginOverride.isEmpty()) {
0133         qCInfo(AKONADISERVER_SEARCH_LOG) << "Overriding the search plugins with: " << pluginOverride;
0134     }
0135 
0136     const QStringList dirs = QCoreApplication::libraryPaths();
0137     for (const QString &pluginDir : dirs) {
0138         const QString path(pluginDir + QStringLiteral("/pim6/akonadi"));
0139         QDir dir(path);
0140         const QStringList fileNames = dir.entryList(QDir::Files);
0141         qCDebug(AKONADISERVER_SEARCH_LOG) << "SEARCH MANAGER: searching in " << path << ":" << fileNames;
0142         for (const QString &fileName : fileNames) {
0143             const QString filePath = path % QLatin1Char('/') % fileName;
0144             std::unique_ptr<QPluginLoader> loader(new QPluginLoader(filePath));
0145             const QVariantMap metadata = loader->metaData().value(QStringLiteral("MetaData")).toVariant().toMap();
0146             if (metadata.value(QStringLiteral("X-Akonadi-PluginType")).toString() != QLatin1StringView("SearchPlugin")) {
0147                 continue;
0148             }
0149 
0150             const QString libraryName = metadata.value(QStringLiteral("X-Akonadi-Library")).toString();
0151             if (loadedPlugins.contains(libraryName)) {
0152                 qCDebug(AKONADISERVER_SEARCH_LOG) << "Already loaded one version of this plugin, skipping: " << libraryName;
0153                 continue;
0154             }
0155 
0156             // When search plugin override is active, ignore all plugins except for the override
0157             if (!pluginOverride.isEmpty()) {
0158                 if (libraryName != pluginOverride) {
0159                     qCDebug(AKONADISERVER_SEARCH_LOG) << libraryName << "skipped because of AKONADI_OVERRIDE_SEARCHPLUGIN";
0160                     continue;
0161                 }
0162 
0163                 // When there's no override, only load plugins enabled by default
0164             } else if (!metadata.value(QStringLiteral("X-Akonadi-LoadByDefault"), true).toBool()) {
0165                 continue;
0166             }
0167 
0168             if (!loader->load()) {
0169                 qCCritical(AKONADISERVER_SEARCH_LOG) << "Failed to load search plugin" << libraryName << ":" << loader->errorString();
0170                 continue;
0171             }
0172 
0173             mPluginLoaders << loader.release();
0174             loadedPlugins << libraryName;
0175         }
0176     }
0177 }
0178 
0179 void SearchManager::initSearchPlugins()
0180 {
0181     for (QPluginLoader *loader : std::as_const(mPluginLoaders)) {
0182         if (!loader->load()) {
0183             qCCritical(AKONADISERVER_SEARCH_LOG) << "Failed to load search plugin" << loader->fileName() << ":" << loader->errorString();
0184             continue;
0185         }
0186 
0187         AbstractSearchPlugin *plugin = qobject_cast<AbstractSearchPlugin *>(loader->instance());
0188         if (!plugin) {
0189             qCCritical(AKONADISERVER_SEARCH_LOG) << loader->fileName() << "is not a valid Akonadi search plugin";
0190             continue;
0191         }
0192 
0193         qCDebug(AKONADISERVER_SEARCH_LOG) << "SearchManager: loaded search plugin" << loader->fileName();
0194         mPlugins << plugin;
0195     }
0196 }
0197 
0198 void SearchManager::scheduleSearchUpdate()
0199 {
0200     // Reset if the timer is active (use QueuedConnection to invoke start() from
0201     // the thread the QTimer lives in instead of caller's thread, otherwise crashes
0202     // and weird things can happen.
0203     QMetaObject::invokeMethod(mSearchUpdateTimer, qOverload<>(&QTimer::start), Qt::QueuedConnection);
0204 }
0205 
0206 void SearchManager::searchUpdateTimeout()
0207 {
0208     // Get all search collections, that is subcollections of "Search", which always has ID 1
0209     const Collection::List collections = Collection::retrieveFiltered(Collection::parentIdFullColumnName(), 1);
0210     for (const Collection &collection : collections) {
0211         updateSearchAsync(collection);
0212     }
0213 }
0214 
0215 void SearchManager::updateSearchAsync(const Collection &collection)
0216 {
0217     QMetaObject::invokeMethod(
0218         this,
0219         [this, collection]() {
0220             updateSearchImpl(collection);
0221         },
0222         Qt::QueuedConnection);
0223 }
0224 
0225 void SearchManager::updateSearch(const Collection &collection)
0226 {
0227     mLock.lock();
0228     if (mUpdatingCollections.contains(collection.id())) {
0229         mLock.unlock();
0230         return;
0231         // FIXME: If another thread already requested an update, we return to the caller before the
0232         // search update is performed; this contradicts the docs
0233     }
0234     mUpdatingCollections.insert(collection.id());
0235     mLock.unlock();
0236     QMetaObject::invokeMethod(
0237         this,
0238         [this, collection]() {
0239             updateSearchImpl(collection);
0240         },
0241         Qt::BlockingQueuedConnection);
0242     mLock.lock();
0243     mUpdatingCollections.remove(collection.id());
0244     mLock.unlock();
0245 }
0246 
0247 void SearchManager::updateSearchImpl(const Collection &collection)
0248 {
0249     if (collection.queryString().size() >= 32768) {
0250         qCWarning(AKONADISERVER_SEARCH_LOG) << "The query is at least 32768 chars long, which is the maximum size supported by the akonadi db schema. The "
0251                                                "query is therefore most likely truncated and will not be executed.";
0252         return;
0253     }
0254     if (collection.queryString().isEmpty()) {
0255         return;
0256     }
0257 
0258     const QStringList queryAttributes = collection.queryAttributes().split(QLatin1Char(' '));
0259     const bool remoteSearch = queryAttributes.contains(QLatin1StringView(AKONADI_PARAM_REMOTE));
0260     bool recursive = queryAttributes.contains(QLatin1StringView(AKONADI_PARAM_RECURSIVE));
0261 
0262     QStringList queryMimeTypes;
0263     const QList<MimeType> mimeTypes = collection.mimeTypes();
0264     queryMimeTypes.reserve(mimeTypes.count());
0265 
0266     for (const MimeType &mt : mimeTypes) {
0267         queryMimeTypes << mt.name();
0268     }
0269 
0270     QList<qint64> queryAncestors;
0271     if (collection.queryCollections().isEmpty()) {
0272         queryAncestors << 0;
0273         recursive = true;
0274     } else {
0275         const QStringList collectionIds = collection.queryCollections().split(QLatin1Char(' '));
0276         queryAncestors.reserve(collectionIds.count());
0277         for (const QString &colId : collectionIds) {
0278             queryAncestors << colId.toLongLong();
0279         }
0280     }
0281 
0282     // Always query the given collections
0283     QList<qint64> queryCollections = queryAncestors;
0284 
0285     if (recursive) {
0286         // Resolve subcollections if necessary
0287         queryCollections += SearchHelper::matchSubcollectionsByMimeType(queryAncestors, queryMimeTypes);
0288     }
0289 
0290     // This happens if we try to search a virtual collection in recursive mode (because virtual collections are excluded from listCollectionsRecursive)
0291     if (queryCollections.isEmpty()) {
0292         qCDebug(AKONADISERVER_SEARCH_LOG) << "No collections to search, you're probably trying to search a virtual collection.";
0293         return;
0294     }
0295 
0296     // Query all plugins for search results
0297     const QByteArray id = "searchUpdate-" + QByteArray::number(QDateTime::currentDateTimeUtc().toSecsSinceEpoch());
0298     SearchRequest request(id, *this, mAgentSearchManager);
0299     request.setCollections(queryCollections);
0300     request.setMimeTypes(queryMimeTypes);
0301     request.setQuery(collection.queryString());
0302     request.setRemoteSearch(remoteSearch);
0303     request.setStoreResults(true);
0304     request.setProperty("SearchCollection", QVariant::fromValue(collection));
0305     connect(&request, &SearchRequest::resultsAvailable, this, &SearchManager::searchUpdateResultsAvailable);
0306     request.exec(); // blocks until all searches are done
0307 
0308     const QSet<qint64> results = request.results();
0309 
0310     // Get all items in the collection
0311     QueryBuilder qb(CollectionPimItemRelation::tableName());
0312     qb.addColumn(CollectionPimItemRelation::rightColumn());
0313     qb.addValueCondition(CollectionPimItemRelation::leftColumn(), Query::Equals, collection.id());
0314     if (!qb.exec()) {
0315         return;
0316     }
0317 
0318     Transaction transaction(DataStore::self(), QStringLiteral("UPDATE SEARCH"));
0319 
0320     // Unlink all items that were not in search results from the collection
0321     QVariantList toRemove;
0322     while (qb.query().next()) {
0323         const qint64 id = qb.query().value(0).toLongLong();
0324         if (!results.contains(id)) {
0325             toRemove << id;
0326             Collection::removePimItem(collection.id(), id);
0327         }
0328     }
0329 
0330     if (!transaction.commit()) {
0331         return;
0332     }
0333 
0334     if (!toRemove.isEmpty()) {
0335         SelectQueryBuilder<PimItem> qb;
0336         qb.addValueCondition(PimItem::idFullColumnName(), Query::In, toRemove);
0337         if (!qb.exec()) {
0338             return;
0339         }
0340 
0341         const QList<PimItem> removedItems = qb.result();
0342         DataStore::self()->notificationCollector()->itemsUnlinked(removedItems, collection);
0343     }
0344 
0345     qCInfo(AKONADISERVER_SEARCH_LOG) << "Search update for collection" << collection.name() << "(" << collection.id() << ") finished:"
0346                                      << "all results: " << results.count() << ", removed results:" << toRemove.count();
0347 }
0348 
0349 void SearchManager::searchUpdateResultsAvailable(const QSet<qint64> &results)
0350 {
0351     const auto collection = sender()->property("SearchCollection").value<Collection>();
0352     qCDebug(AKONADISERVER_SEARCH_LOG) << "searchUpdateResultsAvailable" << collection.id() << results.count() << "results";
0353 
0354     QSet<qint64> newMatches = results;
0355     QSet<qint64> existingMatches;
0356     {
0357         QueryBuilder qb(CollectionPimItemRelation::tableName());
0358         qb.addColumn(CollectionPimItemRelation::rightColumn());
0359         qb.addValueCondition(CollectionPimItemRelation::leftColumn(), Query::Equals, collection.id());
0360         if (!qb.exec()) {
0361             return;
0362         }
0363 
0364         while (qb.query().next()) {
0365             const qint64 id = qb.query().value(0).toLongLong();
0366             if (newMatches.contains(id)) {
0367                 existingMatches << id;
0368             }
0369         }
0370     }
0371 
0372     qCDebug(AKONADISERVER_SEARCH_LOG) << "Got" << newMatches.count() << "results, out of which" << existingMatches.count() << "are already in the collection";
0373 
0374     newMatches = newMatches - existingMatches;
0375     if (newMatches.isEmpty()) {
0376         qCDebug(AKONADISERVER_SEARCH_LOG) << "Added results: 0 (fast path)";
0377         return;
0378     }
0379 
0380     Transaction transaction(DataStore::self(), QStringLiteral("PUSH SEARCH RESULTS"), !DataStore::self()->inTransaction());
0381 
0382     // First query all the IDs we got from search plugin/agent against the DB.
0383     // This will remove IDs that no longer exist in the DB.
0384     constexpr int maximumParametersSize = 1000;
0385     QVariantList newMatchesVariant;
0386     newMatchesVariant.reserve(maximumParametersSize);
0387     QList<PimItem> items;
0388 
0389     for (qint64 id : std::as_const(newMatches)) {
0390         newMatchesVariant << id;
0391         if (newMatchesVariant.size() >= maximumParametersSize) {
0392             SelectQueryBuilder<PimItem> qb;
0393             qb.addValueCondition(PimItem::idFullColumnName(), Query::In, newMatchesVariant);
0394             if (!qb.exec()) {
0395                 return;
0396             }
0397 
0398             items << qb.result();
0399 
0400             newMatchesVariant.clear();
0401         }
0402     }
0403 
0404     if (!newMatchesVariant.isEmpty()) {
0405         SelectQueryBuilder<PimItem> qb;
0406         qb.addValueCondition(PimItem::idFullColumnName(), Query::In, newMatchesVariant);
0407         if (!qb.exec()) {
0408             return;
0409         }
0410 
0411         items << qb.result();
0412     }
0413 
0414     if (items.count() != newMatches.count()) {
0415         qCDebug(AKONADISERVER_SEARCH_LOG) << "Search backend returned" << (newMatches.count() - items.count()) << "results that no longer exist in Akonadi.";
0416         qCDebug(AKONADISERVER_SEARCH_LOG) << "Please reindex collection" << collection.id();
0417         // TODO: Request the reindexing directly from here
0418     }
0419 
0420     if (items.isEmpty()) {
0421         qCDebug(AKONADISERVER_SEARCH_LOG) << "Added results: 0 (no existing result)";
0422         return;
0423     }
0424 
0425     for (const auto &item : items) {
0426         Collection::addPimItem(collection.id(), item.id());
0427     }
0428 
0429     if (!transaction.commit()) {
0430         qCWarning(AKONADISERVER_SEARCH_LOG) << "Failed to commit search results transaction";
0431         return;
0432     }
0433 
0434     DataStore::self()->notificationCollector()->itemsLinked(items, collection);
0435     // Force collector to dispatch the notification now
0436     DataStore::self()->notificationCollector()->dispatchNotifications();
0437 
0438     qCDebug(AKONADISERVER_SEARCH_LOG) << "Added results:" << items.count();
0439 }
0440 
0441 #include "moc_searchmanager.cpp"