Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ path = "src/uniffi_bindgen.rs"
[dependencies]
# Pin to the exact released version. The release workflow updates this line.
# Unreleased v3 core (format v8). Switches to minigraf = "=3.0.0" at the v3.0.0 release.
minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "d9755c02d19a1538ca03fd0b80c56a752bbc6504" }
minigraf = { git = "https://github.com/project-minigraf/minigraf", rev = "32d71ce013a5af0748185fa69403d9151ba94453" }
uniffi = { version = "0.31.1", features = ["cli"] }
thiserror = "2.0.18"
serde_json = "1.0.149"
Expand Down
33 changes: 33 additions & 0 deletions consumer-test/src/test/java/ContractTest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The consumer contract every binding checks (spec §4), from Java: in memory,
// file-backed across a reopen, and the error type for invalid Datalog.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand All @@ -9,6 +10,7 @@
import io.github.project_minigraf.minigraf.MiniGrafException;
import io.github.project_minigraf.minigraf.MiniGrafPreparedQuery;
import io.github.project_minigraf.minigraf.MiniGrafValue;
import io.github.project_minigraf.minigraf.MiniGrafWriteTransaction;
import io.github.project_minigraf.minigraf.Minigraf;
import io.github.project_minigraf.minigraf.Overridable;
import java.nio.file.Files;
Expand Down Expand Up @@ -134,6 +136,37 @@ void overridesOptionsAndSetters() throws Exception {
}
}

// minigraf#824: write transactions through the published package.
@Test
void writeTransactions() throws Exception {
Path dir = Files.createTempDirectory("minigraf-tx");
String path = dir.resolve("tx.graph").toString();
try (MiniGrafDb db = Minigraf.open(path)) {
db.execute("(transact [[:alice :name \"Alice\"]])");
try (MiniGrafWriteTransaction tx = db.beginWriteWith(Minigraf.txOptions().chunkFacts(1000).build())) {
tx.execute("(transact [[:bob :name \"Bob\"]])");
assertTrue(tx.execute("(query [:find ?n :where [?e :name ?n]])").contains("Bob"),
"the transaction reads its own write");
tx.commit();
MiniGrafException finished = assertThrows(MiniGrafException.Api.class,
() -> tx.execute("(query [:find ?n :where [?e :name ?n]])"));
assertTrue(Minigraf.errorMessage(finished).startsWith("[API-018]"), "a finished transaction");
}
assertThrows(IllegalStateException.class, () -> {
try (MiniGrafWriteTransaction tx = db.beginWrite()) {
tx.execute("(transact [[:carol :name \"Carol\"]])");
throw new IllegalStateException("leave the block");
}
});
assertFalse(db.execute("(query [:find ?n :where [?e :name ?n]])").contains("Carol"),
"an exception rolls back");
}
try (MiniGrafDb db = Minigraf.open(path)) {
assertTrue(db.execute("(query [:find ?n :where [?e :name ?n]])").contains("Bob"),
"the commit survives reopen");
}
}

@Test
void invalidDatalogThrowsMiniGrafException() throws Exception {
try (MiniGrafDb db = Minigraf.openInMemory()) {
Expand Down
53 changes: 53 additions & 0 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,59 @@ db.registerAggregate("product", () -> new MiniGrafAccumulator() {
function. Inside a write transaction, the calls the core refuses there still return
`INT-001`.

## Write transactions

A write transaction stages several `transact`, `retract` and `query` statements, with
queries seeing what it staged, and then commits them as one transaction or rolls them
back (minigraf#824). First in the release built on core v3.0.0 (core commit
`32d71ce0`).

```kotlin
val db = MiniGrafDb.open("bank.graph")
db.execute("(transact [[:alice :balance 100] [:bob :balance 50]])")

// use { } closes the transaction on exit, which rolls it back unless it was
// committed, also when an exception leaves the block.
db.beginWrite(TxOptions(chunkFacts = 10_000)).use { tx ->
tx.execute("(transact [[:alice :balance 70] [:bob :balance 80]])")
tx.execute("(retract [[:alice :balance 100] [:bob :balance 50]])")
val staged = tx.execute("(query [:find ?b :where [:alice :balance ?b]])")
if (!staged.contains("-")) tx.commit()
}
```

From Java, `beginWriteWith` takes the options from a builder, and try-with-resources
rolls back unless committed:

```java
try (MiniGrafWriteTransaction tx = db.beginWriteWith(Minigraf.txOptions().chunkFacts(10_000).build())) {
tx.execute("(transact [[:alice :balance 70]])");
tx.commit();
}
```

- `db.beginWrite()` uses the database's options; `db.beginWrite(options)` (Kotlin) or `db.beginWriteWith(options)` (Java, with `Minigraf.txOptions()`) takes `TxOptions`. An option left
`null` is the database's: `synchronous` (`SyncMode.FULL` / `NORMAL`) is this commit's
WAL sync, `chunkFacts` the facts staged before a file database writes the transaction
in chunks (negative is API-017). Read-only databases throw API-014.
- `tx.execute(datalog)` returns what `db.execute` returns; a staged write returns
`{"ok":true}`. A statement that fails stages nothing and leaves the transaction open.
There are no cursors or prepared queries inside a transaction.
- `tx.commit()` applies everything as one transaction; a commit that fails rolls back.
`tx.rollback()` discards it. Either way it is finished: `execute` and `commit` then
throw API-018, and `rollback` does nothing.
- Commit is always explicit. `close()` (and `use { }`, try-with-resources) frees the transaction
and rolls it back unless it committed; after `close()` the object cannot be used. The
garbage collector may never free an abandoned one, so do not rely on it.
- An open transaction holds the database's write lock until it finishes, so end every
transaction explicitly or through `use { }` or try-with-resources. On the thread that began it, database
calls (`execute`, `query`, `prepare`, `factLog`, `checkpoint`, another `beginWrite`, prepared queries) fail with INT-001, and so do user functions its statements run. On
other threads, writes and begins wait until it ends; queries read the committed
state. Use a transaction on the thread that began it: a thread that uses one it did
not begin and then writes through the database waits on that transaction.
- Rules registered inside a transaction stay registered after a rollback.
- The transaction keeps its database open until it is closed.

## Links

- [Full Java/JVM integration guide](https://github.com/project-minigraf/minigraf/wiki/Use-Cases#java--jvm)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ object Minigraf {
@JvmStatic
fun filter(): FactFilterBuilder = FactFilterBuilder()

/**
* A builder for [TxOptions] (minigraf#824); an option left unset is the
* database's. `db.beginWriteWith(Minigraf.txOptions().chunkFacts(10_000).build())`.
*/
@JvmStatic
fun txOptions(): TxOptionsBuilder = TxOptionsBuilder()

/**
* Starts building a new database at [path] from fact records. Use it in
* try-with-resources: `close()` abandons the build unless `finish()` ran.
Expand Down Expand Up @@ -190,6 +197,34 @@ class OpenOptionsBuilder internal constructor() {
fun build(): OpenOptions = options
}

/** Builds [TxOptions] fluently, for Java (minigraf#824). */
class TxOptionsBuilder internal constructor() {
private var options = TxOptions()

/** This transaction's WAL sync at commit. Unset: the database's `synchronous`. */
fun synchronous(mode: SyncMode) = apply { options = options.copy(synchronous = mode) }
/**
* Facts this transaction stages before a file database writes it in chunks.
* Unset: the database's `transactionChunkFacts`. Negative is API-017.
*/
fun chunkFacts(n: Long) = apply { options = options.copy(chunkFacts = n) }
fun build(): TxOptions = options
}

/**
* Begins a write transaction with [options] (minigraf#824); see [MiniGrafDb.beginWrite].
* Use it with `use { }`, which rolls back on exit unless it was committed:
*
* ```kotlin
* db.beginWrite(TxOptions(chunkFacts = 10_000)).use { tx ->
* tx.execute("(transact [[:alice :balance 90]])")
* tx.commit()
* }
* ```
*/
@Throws(MiniGrafException::class)
fun MiniGrafDb.beginWrite(options: TxOptions): MiniGrafWriteTransaction = beginWriteWith(options)

/** Builds a [FactFilter] fluently, for Java. */
class FactFilterBuilder internal constructor() {
private var filter = FactFilter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,21 @@ public MiniGrafValue finish(long count) {
assertEquals("[QRY-014] user function `broken?` failed: java.lang.IllegalArgumentException: from Java",
Minigraf.errorMessage(e));
}

@Test
void writeTransactionFromJava() throws Exception {
MiniGrafDb db = Minigraf.openInMemory();
db.execute("(transact [[:alice :name \"Alice\"]])");
try (MiniGrafWriteTransaction tx = db.beginWriteWith(Minigraf.txOptions().chunkFacts(1000).build())) {
tx.execute("(transact [[:bob :name \"Bob\"]])");
assertTrue(tx.execute("(query [:find ?n :where [?e :name ?n]])").contains("Bob"));
tx.commit();
}
try (MiniGrafWriteTransaction tx = db.beginWrite()) {
tx.execute("(transact [[:carol :name \"Carol\"]])");
}
String all = db.execute("(query [:find ?n :where [?e :name ?n]])");
assertTrue(all.contains("Bob"), all);
assertFalse(all.contains("Carol"), "try-with-resources rolls back unless committed");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package io.github.project_minigraf.minigraf

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.concurrent.thread
import kotlin.test.assertEquals
import kotlin.test.assertFalse

private val txMapper = jacksonObjectMapper()

/** Write transactions (minigraf#824). */
class TransactionTest {
@TempDir
lateinit var dir: Path

private val names = "(query [:find ?n :where [?e :person/name ?n]])"
private val setup = "(transact [[:alice :person/name \"Alice\"] [:bob :person/name \"Bob\"]])"

private fun namesOf(json: String): List<String> =
(txMapper.readValue<Map<String, Any>>(json)["results"] as List<*>)
.map { (it as List<*>)[0] as String }
.sorted()

private fun code(e: MiniGrafException): String = e.text.substring(1, 8)

private fun memDb(): MiniGrafDb = Minigraf.openInMemory().also { it.execute(setup) }

@Test
fun commitReadsItsOwnWritesAndSurvivesReopen() {
val path = dir.resolve("g.graph").toString()
Minigraf.open(path).use { db ->
db.execute(setup)
val tx = db.beginWrite()
assertEquals("{\"ok\":true}", tx.execute("(transact [[:carol :person/name \"Carol\"]])"))
tx.execute("(retract [[:bob :person/name \"Bob\"]])")
assertEquals(listOf("Alice", "Carol"), namesOf(tx.execute(names)))
var outside: List<String>? = null
thread { outside = namesOf(db.execute(names)) }.join(30_000)
assertEquals(listOf("Alice", "Bob"), outside, "another thread reads the committed state")
tx.commit()
assertFalse(tx.isOpen())
tx.close()
assertEquals(listOf("Alice", "Carol"), namesOf(db.execute(names)))
}
Minigraf.open(path).use { db ->
assertEquals(listOf("Alice", "Carol"), namesOf(db.execute(names)), "after reopening")
}
}

@Test
fun useRollsBackUnlessCommitted() {
val db = memDb()
db.beginWrite().use { tx -> tx.execute("(transact [[:carol :person/name \"Carol\"]])") }
assertEquals(listOf("Alice", "Bob"), namesOf(db.execute(names)), "no commit on a normal exit")
assertThrows<IllegalStateException> {
db.beginWrite().use { tx ->
tx.execute("(transact [[:dave :person/name \"Dave\"]])")
throw IllegalStateException("boom")
}
}
assertEquals(listOf("Alice", "Bob"), namesOf(db.execute(names)), "rolled back on an exception")
db.execute("(transact [[:erin :person/name \"Erin\"]])") // the lock is free
db.beginWrite().use { tx ->
tx.execute("(transact [[:frank :person/name \"Frank\"]])")
tx.commit()
}
assertEquals(listOf("Alice", "Bob", "Erin", "Frank"), namesOf(db.execute(names)))
}

@Test
fun rollbackAndFinishedTransaction() {
val db = memDb()
val tx = db.beginWrite()
tx.execute("(transact [[:carol :person/name \"Carol\"]])")
tx.rollback()
assertEquals(listOf("Alice", "Bob"), namesOf(db.execute(names)))
assertEquals("API-018", code(assertThrows<MiniGrafException> { tx.execute(names) }))
assertEquals("API-018", code(assertThrows<MiniGrafException> { tx.commit() }))
tx.rollback() // does nothing
tx.close()
}

@Test
fun failedStatementStaysOpen() {
val db = memDb()
db.beginWrite().use { tx ->
assertEquals("PRS-004", code(assertThrows<MiniGrafException> { tx.execute("(transact [[:a") }))
tx.execute("(transact [[:carol :person/name \"Carol\"]])")
tx.commit()
}
assertEquals(listOf("Alice", "Bob", "Carol"), namesOf(db.execute(names)))
}

@Test
fun beginThreadGetsInt001AndOtherThreadsWait() {
val db = memDb()
val tx = db.beginWrite()
assertEquals("INT-001", code(assertThrows<MiniGrafException> { db.execute(names) }))
var wrote = false
val writer = thread { db.execute("(transact [[:w :person/name \"W\"]])"); wrote = true }
writer.join(300)
assertFalse(wrote, "a write from another thread waits")
tx.commit()
writer.join(30_000)
assertEquals(listOf("Alice", "Bob", "W"), namesOf(db.execute(names)))
}

@Test
fun options() {
val db = memDb()
assertEquals(
"API-017",
code(assertThrows<MiniGrafException> { db.beginWrite(Minigraf.txOptions().chunkFacts(-1).build()) }),
)
val path = dir.resolve("chunk.graph").toString()
Minigraf.open(path).use { file ->
file.beginWrite(TxOptions(synchronous = SyncMode.NORMAL, chunkFacts = 2)).use { tx ->
for (i in 0 until 5) tx.execute("(transact [[:p$i :person/name \"P$i\"]])")
tx.commit()
}
}
Minigraf.open(path).use { file -> assertEquals(5, namesOf(file.execute(names)).size) }
val ro = Minigraf.openWithOptions(path, Minigraf.options().readOnly(true).build())
assertEquals("API-014", code(assertThrows<MiniGrafException> { ro.beginWrite() }))
ro.close()
}
}
Loading
Loading