diff --git a/docker-compose.yaml b/docker-compose.yaml index c3bd0214..0ec1d84f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,4 +4,11 @@ services: image: mongo:4 container_name: o-neko-mongodb ports: - - "27017:27017" \ No newline at end of file + - "27017:27017" + + meilisearch: + image: getmeili/meilisearch:v1.0.2 + ports: + - "7700:7700" + environment: + - MEILI_MASTER_KEY=ZWEZtwJKXZX3mQGA9hlqKr7THr2UTcHOKThuV8aWq3A \ No newline at end of file diff --git a/pom.xml b/pom.xml index ecfce27d..3facd0fe 100644 --- a/pom.xml +++ b/pom.xml @@ -197,6 +197,11 @@ springdoc-openapi-security ${springdoc.version} + + com.meilisearch.sdk + meilisearch-java + 0.11.0 + org.awaitility awaitility diff --git a/src/main/java/io/oneko/search/impl/DatabaseSearchService.java b/src/main/java/io/oneko/search/impl/DatabaseSearchService.java index 22abb6aa..dfeb3754 100644 --- a/src/main/java/io/oneko/search/impl/DatabaseSearchService.java +++ b/src/main/java/io/oneko/search/impl/DatabaseSearchService.java @@ -12,9 +12,11 @@ import io.oneko.search.SearchResult; import io.oneko.search.VersionSearchResultEntry; import java.util.concurrent.TimeUnit; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @Service +@ConditionalOnProperty(value = "o-neko.search.meilisearch.enabled", havingValue = "false", matchIfMissing = true) public class DatabaseSearchService extends MeasuringSearchService { private final ProjectRepository projectRepository; diff --git a/src/main/java/io/oneko/search/impl/meilisearch/MeilisearchSearchService.java b/src/main/java/io/oneko/search/impl/meilisearch/MeilisearchSearchService.java new file mode 100644 index 00000000..bb2f9a0c --- /dev/null +++ b/src/main/java/io/oneko/search/impl/meilisearch/MeilisearchSearchService.java @@ -0,0 +1,178 @@ +package io.oneko.search.impl.meilisearch; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.gson.Gson; +import com.meilisearch.sdk.Client; +import com.meilisearch.sdk.Config; +import com.meilisearch.sdk.Index; +import com.meilisearch.sdk.SearchRequest; +import com.meilisearch.sdk.exceptions.MeilisearchException; +import com.meilisearch.sdk.json.GsonJsonHandler; +import com.meilisearch.sdk.model.Searchable; +import io.micrometer.core.instrument.MeterRegistry; +import io.oneko.event.EventDispatcher; +import io.oneko.project.ProjectRepository; +import io.oneko.project.ReadableProject; +import io.oneko.project.ReadableProjectVersion; +import io.oneko.project.event.ProjectDeletedEvent; +import io.oneko.project.event.ProjectSavedEvent; +import io.oneko.search.MeasuringSearchService; +import io.oneko.search.ProjectSearchResultEntry; +import io.oneko.search.SearchResult; +import io.oneko.search.VersionSearchResultEntry; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(value = "o-neko.search.meilisearch.enabled", havingValue = "true") +public class MeilisearchSearchService extends MeasuringSearchService { + + private final ObjectMapper objectMapper; + private final Index projectIndex; + private final Index versionIndex; + private final Gson gson = new Gson(); + private final Cache queryResultCache = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .maximumSize(512) + .build(); // TODO: Evaluate if cache is really useful in combination with Meilisearch + + public MeilisearchSearchService(ProjectRepository projectRepository, + EventDispatcher eventDispatcher, + ObjectMapper objectMapper, + MeterRegistry meterRegistry) throws MeilisearchException { + super(meterRegistry); + + this.objectMapper = objectMapper; + + Client client = new Client( + new Config("http://localhost:7700", "ZWEZtwJKXZX3mQGA9hlqKr7THr2UTcHOKThuV8aWq3A", new GsonJsonHandler())); // TODO: Read values from config + projectIndex = client.index("oneko_projects"); + versionIndex = client.index("oneko_versions"); + + boolean initIndexes = true; // TODO: Check if index exists or is empty + + eventDispatcher.registerListener(event -> { + if (event instanceof ProjectSavedEvent pse) { + UUID projectId = pse.describeEntityChange() + .getId(); + projectRepository.getById(projectId) + .ifPresent(project -> { + delete(project.getId() + .toString()); + indexProject(project); + }); + queryResultCache.invalidateAll(); + } else if (event instanceof ProjectDeletedEvent pde) { + String deletedProjectId = pde.describeEntityChange() + .getId() + .toString(); + delete(deletedProjectId); + queryResultCache.invalidateAll(); + } + }); + + if (initIndexes) { + CompletableFuture.runAsync(() -> projectRepository.getAll() + .forEach(this::indexProject)); + } + } + + private void indexProject(ReadableProject project) { + ProjectMeili projectMeili = toProjectMeili(project); + try { + String json = gson.toJson(projectMeili); + projectIndex.addDocuments(json, "id"); + } catch (MeilisearchException e) { + throw new RuntimeException(e); + } + project.getVersions() + .forEach(this::indexVersion); + } + + private void indexVersion(ReadableProjectVersion version) { + VersionMeili versionMeili = toVersionMeili(version); + try { + String json = gson.toJson(versionMeili); + versionIndex.addDocuments(json, "id"); + } catch (MeilisearchException e) { + throw new RuntimeException(e); + } + } + + private ProjectMeili toProjectMeili(ReadableProject project) { + return ProjectMeili.builder() + .id(project.getId()) + .name(project.getName()) + .containerImage(project.getImageName()) + .versionNames(project.getVersions() + .stream() + .map(ReadableProjectVersion::getName) + .toList()) + .build(); + } + + private VersionMeili toVersionMeili(ReadableProjectVersion version) { + ReadableProject project = version.getProject(); + return VersionMeili.builder() + .id(version.getId()) + .name(version.getName()) + .projectId(project.getId()) + .projectName(project.getName()) + .build(); + } + + private void delete(String projectId) { + try { + projectIndex.deleteDocument(projectId); + Searchable search = versionIndex.search(new SearchRequest("").setFilter(new String[]{"projectId = " + projectId})); + search.getHits() + .stream() + .map(h -> h.get("id")) + .forEach(id -> { + if (id instanceof String) { + try { + versionIndex.deleteDocument((String) id); + } catch (MeilisearchException e) { + throw new RuntimeException(e); + } + } + }); + } catch (MeilisearchException e) { + throw new RuntimeException(e); + } + } + + @Override + public SearchResult findProjectsAndVersionsInternal(String searchTerm) { + return queryResultCache.get(searchTerm, s -> { + try { + var versions = versionIndex.search(searchTerm) + .getHits() + .stream() + .map(res -> objectMapper.convertValue(res, VersionMeili.class)) + .map(vm -> new VersionSearchResultEntry(vm.getName(), vm.getId(), vm.getProjectName(), vm.getProjectId())) + .toList(); + + var projects = projectIndex.search(searchTerm) + .getHits() + .stream() + .map(res -> objectMapper.convertValue(res, ProjectMeili.class)) + .map(pm -> new ProjectSearchResultEntry(pm.getName(), pm.getId())) + .toList(); + + return SearchResult.builder() + .query(searchTerm) + .projects(projects) + .versions(versions) + .build(); + } catch (MeilisearchException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/src/main/java/io/oneko/search/impl/meilisearch/ProjectMeili.java b/src/main/java/io/oneko/search/impl/meilisearch/ProjectMeili.java new file mode 100644 index 00000000..8fc9f19a --- /dev/null +++ b/src/main/java/io/oneko/search/impl/meilisearch/ProjectMeili.java @@ -0,0 +1,21 @@ +package io.oneko.search.impl.meilisearch; + +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProjectMeili { + + private UUID id; + private String name; + private String containerImage; + private List versionNames; + +} diff --git a/src/main/java/io/oneko/search/impl/meilisearch/VersionMeili.java b/src/main/java/io/oneko/search/impl/meilisearch/VersionMeili.java new file mode 100644 index 00000000..15aa4962 --- /dev/null +++ b/src/main/java/io/oneko/search/impl/meilisearch/VersionMeili.java @@ -0,0 +1,20 @@ +package io.oneko.search.impl.meilisearch; + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VersionMeili { + + private UUID id; + private String name; + private UUID projectId; + + private String projectName; +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3b54cbdd..ef4c83a4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -61,6 +61,9 @@ logging: o-neko: security: credentialsCoderKey: VJxDYI6zT9gLLfY9MyDGf2nxQ8mY7DcECxTDqKIV + search: + meilisearch: + enabled: true kubernetes: auth: