Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c705a95
feat(search): Kotlin Multiplatform search for iOS and Android 15+
kdroidFilter Oct 15, 2026
bea36a3
fix(search): release the iOS runtime's threads and close what the eng…
kdroidFilter Oct 16, 2026
f918860
refactor(search): carry the iOS bridge's calls in CBOR rather than JSON
kdroidFilter Oct 16, 2026
605ad3c
fix(search): send the bridge's bytes as CBOR byte strings and refuse …
kdroidFilter Oct 16, 2026
5ffa17f
build(search): run the iOS search on OpenJDK 27 GA (jdk-mobile-27b35.1)
kdroidFilter Oct 16, 2026
2b209bd
feat(search): Lucene on a real JVM on Android too, from Android 10
kdroidFilter Oct 16, 2026
0328a3f
build(search): jdk-mobile-27b35.2, with the Android runtime for x86_6…
kdroidFilter Oct 16, 2026
e6d3dd3
refactor(search): Lucene through Nucleus JVM Access, on every platform
kdroidFilter Oct 16, 2026
d5d8eeb
build: Nucleus JVM Access 0.2.0 from its release
kdroidFilter Oct 17, 2026
18fd79c
build: jdk-mobile's runtimes downloaded by Nucleus JVM Access 0.3.0
kdroidFilter Oct 17, 2026
20189d0
fix(search): read the index through NIO on Android
kdroidFilter Oct 17, 2026
302c19b
feat(search): Lucene compiled into a GraalVM native image on iOS, thr…
kdroidFilter Oct 17, 2026
511be77
refactor(dao): ConcurrentHashMap's caches on the JVM and Android, cac…
kdroidFilter Oct 17, 2026
baa86a6
refactor: the concurrent caches in a module of their own, :cache
kdroidFilter Oct 17, 2026
7027874
fix(search): port the long-query clause budget (#135) to the common e…
kdroidFilter Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions JVM_RUNTIME.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Lucene on a JVM, on every platform

Lucene needs a JVM, and keeps up with the newest Java (21 for Lucene 10, 25 for Lucene 11): ART can't, so the search
runs Lucene on a real JVM everywhere, and everything else (query building, dictionary, ranking, snippets, highlights,
fusion, the semantic model) in common Kotlin. The bridge between both is
[Nucleus JVM Access](https://github.com/NucleusFramework/NucleusJvmAccess) (NJA).

```
search (Kotlin Multiplatform) search-facade (Kotlin/JVM): what runs on the JVM
────────────────────────────── ─────────────────────────────────────────────────
LexicalSearchEngine, MagicDictionaryIndex, snippets,
highlights... use the façade's API as if it were LuceneIndex(indexDir, cacheQueries): analyze, search,
local: io.github...search.index.LuceneIndex, ──▶ cursors, facets, collectLongs, count, KNN
IndexQuery, IndexPage, KnnRequest... (generated by IndexQuery (a sealed value), IndexPage, KnnRequest...
NJA from the façade's sources) LuceneIndexBackend (internal): Lucene 10
```

| | Desktop (JVM) | Android 10+ | iOS |
|---|---|---|---|
| The JVM | the app's own | HotSpot (server VM, JIT), in the app's process next to ART | none: the façade compiled ahead of time into a GraalVM native image, linked into the app (iOS forbids generated code) |
| A call | a plain call into NJA's dispatcher, on the calling thread | handed to a HotSpot thread paired with the calling one | a call into the image's entry point, on the calling thread |

- `search-facade` holds the façade: its public API (`LuceneIndex`, `IndexCursor` and the value types: data classes,
enums, the sealed `IndexQuery`) is what NJA exports; `LuceneIndexBackend` is internal.
- NJA generates, from the façade's sources, the proxies and value types `search`'s common code uses, in
`io.github.kdroidfilter.seforimlibrary.search.index` (`njaFacade.clientPackage`: apart from the façade's own package,
as the desktop has both on its class path). Query helpers built in the app (`bool { }`) stay in common code.
- `LuceneIndex(path)` is the same constructor on every platform; `close()` closes the JVM's index.

## The runtimes

`nucleusJvm.iosRuntime` is `GRAALVM`: Zero, jdk-mobile's interpreter, searched about 8× slower than the native image
(a first page 1.2 s against 140 ms in the simulator, on real data). Android keeps HotSpot: an Android native image
builds on Linux only, and HotSpot is the fastest once warm.

- **iOS**: NJA downloads [graalvm-mobile](https://github.com/NucleusFramework/graalvm-mobile)'s latest kit (GraalVM
CE's static libraries, for iOS) and the GraalVM CE release it names, for the build host, and compiles the façade
into `search/build/nja/nativeImage/<target>/nja.o` (`njaNativeImage<Target>`). The binaries link it with the kit's
libraries (`-export_dynamic`: NJA's client finds the image's entry points at run time): nothing to bundle. Lucene's
reflection and resources are in the façade's jar (`META-INF/native-image/.../reachability-metadata.json`), traced on
the JVM tests with the real index: `./gradlew :search:jvmTest -PindexDir=<seforim.db.lucene>
-PnativeImageAgent=<GraalVM home>` (then drop what the tests alone reach). The image reads the index through NIO.
For `search`'s own tests NJA does it all; an app links as Zayit's `searchkit` shows (`assembleIosJavaRuntimeDevice`:
`static/`, the object and the kit's libraries).

Android's JVM is jdk-mobile's: a stable OpenJDK (27, `openjdk/jdk27u`'s GA releases) with openjdk/mobile's port,
built by the CI of [NucleusFramework/openjdk-mobile](https://github.com/NucleusFramework/openjdk-mobile) and published
as releases named like the JBR's, `jdk-mobile-<version>b<build>.<revision>` (`jdk-mobile-27b35.3`: `jdk-27-ga`, the
JDK's build 35). NJA downloads the latest one (the runtimes `search` needs: arm64-v8a and x86_64 Android),
checks them against their `.sha256`, keeps them in Gradle's cache and extracts them into `search/build/nja/jdkMobile`;
`nucleusJvm.jdkMobileRelease` pins one.

- **Android**: NJA links HotSpot and the JDK's natives into `libnja.so` (one per ABI: arm64-v8a, x86_64) and puts the
runtime (`java.home` per ABI, the façade's class path, the VM's options) in `search`'s assets, extracted once per
version on the device: an app depending on `search` gets both through the library, with nothing to configure.
- **The desktop**: the façade is on the app's class path; nothing else.
- The VM's options are `nucleusJvm.jvmOptions` (a 128 MB heap: about 140 MB of memory on Android; the native image
keeps the heap's maximum, about 60 MB of memory on iOS for Lucene alone).

## Tests

```bash
./gradlew :search:jvmTest -PindexDir=<seforim.db.lucene> -PluceneQueryCache=off # build/parity/jvm.txt
./gradlew :search:iosSimulatorArm64Test -PindexDir=<seforim.db.lucene> -PluceneQueryCache=off # build/parity/ios.txt
```

They dump what the search answers on a real index (`SearchDump`). `-PluceneQueryCache=off` turns off Lucene's query
cache: it changes the last digits of the scores from one search to the next. Two runs differ anyway by Lucene's
process-dependent clause order (`BooleanQuery` rewrites through a `HashMap` keyed by class identity hashes), so the
comparison allows score differences: facets, snippets and highlights must be identical. On Android, Zayit's
`searchkit-check` app runs the whole search on a device or an emulator.

Verified on the real index (7M documents): the desktop, the iOS simulator and Android 11 emulator give the same facets,
snippets, highlights, titles and find-in-book candidates (Zayit's `searchkit`, hybrid with the semantic search: the
same dump as the desktop, but for a hit total Lucene counts as a lower bound).
68 changes: 68 additions & 0 deletions cache/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@

plugins {
alias(libs.plugins.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.maven.publish)
}

group = "io.github.kdroidfilter.seforimlibrary"

/** Caches read and written from several threads at once, on every platform (ConcurrentCache.kt). */
kotlin {
jvmToolchain(libs.versions.jvmToolchain.get().toInt())

android {
namespace = "io.github.kdroidfilter.seforimlibrary.cache"
compileSdk = 35
minSdk = 21
}
jvm()
iosArm64()
iosSimulatorArm64()

sourceSets {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.core)
}

// No java.util.concurrent on iOS: cachemap's map, lock-free reads
iosMain.dependencies {
implementation(libs.cachemap)
}
}

}

//Publishing your Kotlin Multiplatform library to Maven Central
//https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-publish-libraries.html
mavenPublishing {
publishToMavenCentral()
coordinates("io.github.kdroidfilter.seforimlibrary", "cache", "1.0.0")

pom {
name = "SeforimLibrary"
description = "Kotlin Multiplatform library"
url = "github url" //todo

licenses {
license {
name = "MIT"
url = "https://opensource.org/licenses/MIT"
}
}

developers {
developer {
id = "" //todo
name = "" //todo
email = "" //todo
}
}

scm {
url = "github url" //todo
}
}
if (project.hasProperty("signing.keyId")) signAllPublications()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.github.kdroidfilter.seforimlibrary.cache

import java.util.concurrent.ConcurrentHashMap

actual fun <K : Any, V : Any> concurrentCache(): ConcurrentCache<K, V> =
object : ConcurrentCache<K, V> {
private val map = ConcurrentHashMap<K, V>()

override fun get(key: K): V? = map[key]

override fun set(
key: K,
value: V,
) {
map[key] = value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.github.kdroidfilter.seforimlibrary.cache

/** A cache read and written from several threads at once: concurrent lookups of the same key may both load it. */
interface ConcurrentCache<K : Any, V : Any> {
operator fun get(key: K): V?

operator fun set(
key: K,
value: V,
)
}

/** On the JVM and Android, ConcurrentHashMap; on iOS (no java.util.concurrent), cachemap's lock-free reads. */
expect fun <K : Any, V : Any> concurrentCache(): ConcurrentCache<K, V>
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.github.kdroidfilter.seforimlibrary.cache

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class ConcurrentCacheTest {
@Test
fun storesAndReplacesValues() {
val cache = concurrentCache<String, Int>()
assertNull(cache["a"])
cache["a"] = 1
cache["a"] = 2
assertEquals(2, cache["a"])
}

@Test
fun keepsEveryValueWrittenFromSeveralThreads() =
runBlocking {
val cache = concurrentCache<Int, Int>()
(0 until 8)
.map { worker ->
async(Dispatchers.Default) { for (i in 0 until 1_000) cache[worker * 1_000 + i] = i }
}.awaitAll()
for (key in 0 until 8_000) assertEquals(key % 1_000, cache[key])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.github.kdroidfilter.seforimlibrary.cache

import io.github.charlietap.cachemap.cacheMapOf

actual fun <K : Any, V : Any> concurrentCache(): ConcurrentCache<K, V> =
object : ConcurrentCache<K, V> {
private val map = cacheMapOf<K, V>()

override fun get(key: K): V? = map[key]

override fun set(
key: K,
value: V,
) {
map[key] = value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.github.kdroidfilter.seforimlibrary.cache

import java.util.concurrent.ConcurrentHashMap

actual fun <K : Any, V : Any> concurrentCache(): ConcurrentCache<K, V> =
object : ConcurrentCache<K, V> {
private val map = ConcurrentHashMap<K, V>()

override fun get(key: K): V? = map[key]

override fun set(
key: K,
value: V,
) {
map[key] = value
}
}
4 changes: 3 additions & 1 deletion core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ kotlin {
jvmToolchain(libs.versions.jvmToolchain.get().toInt())

android {
namespace = "io.github.kdroidfilter.seforimlibrary"
namespace = "io.github.kdroidfilter.seforimlibrary.core"
compileSdk = 35
minSdk = 21
}
jvm()
iosArm64()
iosSimulatorArm64()

sourceSets {
commonMain.dependencies {
Expand Down
10 changes: 9 additions & 1 deletion dao/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@ kotlin {
jvmToolchain(libs.versions.jvmToolchain.get().toInt())

android {
namespace = "io.github.kdroidfilter.seforimlibrary"
namespace = "io.github.kdroidfilter.seforimlibrary.dao"
compileSdk = 35
minSdk = 21
}
jvm()
iosArm64()
iosSimulatorArm64()

sourceSets {
commonMain.dependencies {
api(project(":core"))
implementation(project(":cache"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.protobuf)
implementation(libs.kotlinx.datetime)
implementation(libs.kermit)
implementation(libs.kotlinx.io.core)
}

commonTest.dependencies {
Expand All @@ -48,6 +52,10 @@ kotlin {
implementation(libs.sqlDelight.driver.sqlite)
}

iosMain.dependencies {
implementation(libs.sqlDelight.driver.native)
}

}

}
Expand Down
Loading
Loading