Before production
The quickstart uses every default. How it works explains the model this page assumes. The defaults suit one machine with a directory remote. Before releasing an application whose devices share a bucket, decide these settings:
- Remote and credentials: choose the storage service and how each device receives credentials.
- Encryption: choose whether to encrypt, where to keep the encryption key, and how to change it.
- Device identity: choose a separate database directory for each device.
- Local storage: choose the budget and compaction threshold for each device class.
- Synchronization: choose the schedule and how to handle automatic outcomes.
- Retention: choose how many revisions the remote keeps.
- Old revisions: choose how much a snapshot fetches and where it stores data.
- Results: choose a row limit and how to handle SQL values.
- Transactions: choose how connections and threads perform writes.
- Conflicts: choose how to handle replay and quarantine.
- Schema changes: choose how to record each migration.
- Errors: choose an action for each error category.
- The license key: choose how to supply it before the trial ends.
- Closing: choose how to close owners and stop their threads or processes.
The options reference gives the values, generated from the code. This page explains what each setting decides and the consequences of a wrong choice. Names use Python spellings. Each language’s reference gives its own spellings.
The remote and its credentials
Section titled “The remote and its credentials”One bucket namespace holds one application’s data. The installation page gives each provider’s constructor.
| Environment | Credentials |
|---|---|
| Server | Configure the provider’s standard credential chain before opening the database. |
| Device | Have the application’s backend issue short-lived credentials scoped to the namespace. The SDK refreshes them before expiry. The device carries no long-lived key. |
| Browser | Supply a credentials function from the page. Configure the bucket’s CORS to admit the origin. |
Conditional writes
Section titled “Conditional writes”An S3-compatible storage service can hold a database only if it enforces both conditional writes:
- It refuses a create under
If-None-Match: *when the object exists. - It refuses a replacement under
If-Matchwhen the object’s ETag has changed.
The remote’s head and floor change only through these writes. A service that ignores either lets two devices overwrite head and each believe it won.
| Storage service | Verification and support |
|---|---|
| Amazon S3 | This release exercises it. |
| MinIO | The test suite exercises it. |
| Wasabi and IDrive e2 | Both passed the two conditions and conformance suites on 29 September 2026, using endpoint and force_path_style. |
| Backblaze B2 | Unsupported. Its S3-compatible API answers both conditional writes with 501 Not Implemented. |
Before creating a database, potluckdb tests both writes against a probe object. The service must refuse them. If it accepts either or answers 501, creation fails with unsupported_storage and creates no database.
This check shows only what the service did when asked. Any other service still needs the conformance suites before you place a database on it.
Custom storage adapters
Section titled “Custom storage adapters”A storage adapter written in your application’s language can be passed to Remote.from_storage. It must not call the database whose operation invoked it. Closing that database inside the adapter fails with invalid_state. Any other call, including status(), may wait for the operation that is waiting for the adapter. Neither call then returns.
Encryption
Section titled “Encryption”encryption_key encrypts every published piece with a 32-byte encryption key that the application holds. The bucket holds ciphertext and a key identity. The bucket’s access control is therefore the second layer of protection, not the first. Keep the encryption key out of the bucket and the application’s source.
To change the encryption key, use admin.reencrypt. It migrates one retained revision into a fresh remote under a new encryption key. The advanced page describes it.
What encryption reveals
Section titled “What encryption reveals”Encryption hides a page’s contents but reveals which pages are equal. Each piece’s name is the digest of its unencrypted page. Anyone who can list the bucket can identify pages that two remotes share, even under different encryption keys. They can also confirm a guess at a whole page. For a small table of guessable values, such as a PIN or status, this recovers the values. Keep the bucket’s listing as private as its objects.
Failed opens and interrupted rotation
Section titled “Failed opens and interrupted rotation”In this release, these refused opens leave the directory holding the encryption key supplied to that open:
- The first open of a new directory fails with
wrong_key_or_corruptorencryption_key_required. open_revisioninto an existing directory fails with either code.
Every later open there fails with invalid_state. The directory holds no data at that point.
- Delete the directory.
- Open it again with the correct encryption key.
In this release, an interrupted first publication or admin.reencrypt can leave pieces in a namespace. Those pieces belong to the encryption key that wrote them. Publishing or rotating there with another encryption key, or without one, fails with internal_failure until those objects are deleted.
Retry an interrupted rotation with the same new encryption key and source revision. Before a device with another encryption key uses a namespace with no head, delete that namespace’s objects.
A rotation without revision rotates the source’s current head. An interrupted rotation can already have published its target. If the source then publishes again, retrying without revision fails with invalid_state because the target holds the earlier revision. Pass the source revision to every attempt. If the source has already moved on and you need to rotate again:
- Delete the target namespace’s objects.
- Run the rotation again.
Device identity
Section titled “Device identity”Each database directory carries the identity of the device that writes it. potluckdb generates it on first open and persists it beside the data. Two devices never share a database directory. One directory holds one open database at a time.
client_id lets deployment tools fix the identity in advance. Otherwise, an application never sets it. A value that disagrees with the persisted identity is refused.
Paths and links
Section titled “Paths and links”As in SQLite, a relative database or directory-remote path resolves against the working directory once, at open. Later changes to the working directory do not affect it.
As in SQLite, hard links that give database files another name are unsupported. Two directories that share the container through a hard link each accept writes. Neither directory can then be opened.
As in SQLite, do not move, rename or replace a database’s directory or any directory above it while a database or snapshot in it is open. Changing a symbolic link on that path to point elsewhere is also unsupported. The library keeps writing through the path. Its writes and snapshot-directory removal can then reach whatever the path names, including another database.
In this release, the Node.js library reads .. in a path as text before it follows symbolic links. In link/../name, it cancels link. The operating system and the other libraries go to the parent of the link’s target instead. The identity can then reside in one directory and the data in another. Opening the database by its real path fails with invalid_state. In Node.js, use paths without .. after a symbolic link.
Interrupted identity creation
Section titled “Interrupted identity creation”In this release, a process can end during the first open before writing the identity. It can leave an empty .potluckdb-client-id that later opens refuse with invalid_argument. The directory holds no data at that point.
The file is also briefly empty while another process creates the database. An open during that interval fails in the same way.
- Try opening the directory again before deleting the file.
- If the open still fails, delete the file, but only when no other process is opening that directory.
- Open the directory again after deleting the file.
Local storage
Section titled “Local storage”Budget and compaction
Section titled “Budget and compaction”local_budget_bytes bounds what the local store keeps on the device. The default suits a laptop rather than a phone. Choose the budget per device class.
| Condition | Error |
|---|---|
| Writes exceed the budget faster than synchronization frees space | local_budget_exceeded |
The budget is below minimum_local_budget_bytes | Refused at open |
| The disk is full | local_storage_full |
The local file retains reclaimable space until compaction. The store compacts once the file reaches local_compaction_bytes and at least half is reclaimable. The file can therefore reach about twice local_budget_bytes, or local_compaction_bytes when larger. For a 16 MiB phone budget, the default local_compaction_bytes of 256 MiB is the larger value. Leave room for that file size, and lower local_compaction_bytes with a small budget.
Eviction and additional connections
Section titled “Eviction and additional connections”In this release, a small budget can cause the store to evict published pieces. After routine use, opening another connection can then fail with integrity_failure and the message unpublished piece is missing. Nothing is lost, and the database’s own connection keeps working.
- Close the database.
- Open it again.
Budget for pulls and pending writes
Section titled “Budget for pulls and pending writes”In this release, the budget must hold the whole database and the device’s pending writes. After another device publishes a change, a device with a smaller budget fails every sync() and resolve_conflict() with local_budget_exceeded. Each attempt downloads the whole database again, and automatic synchronization stops. If the refusal happens while the device’s own changes are being reapplied, those statements also fail with that code.
Nothing is lost, and the device keeps its writes. Open the database again with a budget that holds it. Synchronization resumes.
Databases without a remote
Section titled “Databases without a remote”In this release, the budget holds every version of a 64 KiB piece that a transaction changes until synchronization publishes it. A database opened without a remote never publishes. In a database without a remote, if each transaction changes one piece, writes fail with local_budget_exceeded after about local_budget_bytes divided by 65,536 transactions. That is about 16,000 transactions at the default budget and about 160 at the minimum.
Nothing is lost. Writes resume after one of these actions:
- Open the database again with a larger budget.
- Open the database again with a remote, then synchronize.
Size the budget of a database without a remote for every transaction it will ever commit.
A database opened without a remote reads only pieces already on its device. A device might previously have opened it with a remote without reading or prefetching every piece. A query needing an unfetched piece then fails with data_missing, even though the remote still holds it. Nothing is lost. Open the database with its remote again.
Memory for transaction records
Section titled “Memory for transaction records”In this release, the local store keeps about 200 bytes per transaction, publication and pulled revision it has applied. It keeps these records in its file and, while open, in memory. A device that commits and synchronizes a million transactions holds about 600 MB of memory for them.
Crashes and power loss
Section titled “Crashes and power loss”As in SQLite, a crash or power loss never prevents the local store from opening. The next open discards only the write that was never acknowledged.
In this release, the one exception is a power loss during the first open of a new database. It can leave a store that fails every open with integrity_failure and the message local group record header is invalid. The application has written nothing to it.
- Delete the database’s directory.
- Open it again.
Disk damage
Section titled “Disk damage”As with SQLite’s WAL, damage from anything other than a crash, such as a failing disk, ends the log at a damaged transaction record. Damage to the part of the store that compaction wrote instead fails the open with integrity_failure.
Synchronization
Section titled “Synchronization”Schedule
Section titled “Schedule”A writable database with a remote synchronizes automatically, using SyncPolicy.automatic timers. Decide the idle poll interval. While nothing changes, each device reads the remote head once per interval. Divide the number of devices by the interval in seconds to find the remote’s reads per second.
The active follow-up, outbound debounce and retry range rarely need changing. SyncPolicy.manual() gives the schedule to the application. The application then calls sync() itself and receives each outcome as a value.
Memory per revision
Section titled “Memory per revision”In this release, synchronization publishes the writes it prepared as one revision. A failed attempt keeps its prepared writes for the next attempt and leaves later writes pending. The revision records each statement with its parameters. The library holds the revision in memory while building or reading it.
Memory therefore grows with the writes in that revision, not with the database size.
| Writes | Approximate memory per MB written |
|---|---|
| Small rows, writing device | 60 MB |
| Small rows, device pulling the revision or opening while it is newest | 50 MB |
| Large values written as blobs, writing device | 18 MB |
While a device is online, the automatic driver publishes within moments of each commit. Each revision then stays small. Long periods of offline writing, an import followed by one sync(), or a single large transaction produce a large revision. On a phone, a few tens of MB of small rows between synchronizations can make the operating system end the process at every attempt. The device then cannot synchronize. Nothing is lost because the writes remain on the device.
- During an import, synchronize every few thousand rows.
- Keep large files outside the database.
- Bound what a device writes while offline.
Continuous writes from many devices
Section titled “Continuous writes from many devices”In this release, a device whose push finds a newer revision replays its pending transactions one at a time before it pushes again. If another device publishes during replay, replay starts again. With many devices writing one database continuously, a device with many pending transactions can repeatedly lose to them. Its own writes lengthen each replay. It publishes only when the others leave the remote unchanged for the length of its replay.
Nothing is lost. The writes remain on the device and publish as soon as writing elsewhere pauses. Meanwhile, pending writes count against local_budget_bytes, as on an offline device.
Replay time grows more with the number of pending transactions than with their rows. With many devices writing one database continuously, group writes into fewer transactions. Treat a device whose on_sync repeatedly reports Conflict without Pushed as one that cannot publish until the others pause.
Calls during synchronization
Section titled “Calls during synchronization”While synchronization holds the database, another thread’s call fails with temporary database_busy and ran nothing. This includes an application call while the automatic driver pulls. Retry it, as after a SQLite lock.
In the browser and in Node.js with a remote from Remote.fromStorage, every database call waits for synchronization to end and then runs. The library runs these calls one at a time.
In this release, these other calls instead wait for synchronization to end and then run:
| Environment | Calls that wait |
|---|---|
| All languages | Calls on the database object while the application’s own sync() or resolve_conflict() runs on another thread |
| Languages other than Python and Node.js | The first statement on a connection from connect(), which completes setup as connect() itself does |
A storage adapter must not wait for a thread that can make such a call.
Shared remotes in Node.js
Section titled “Shared remotes in Node.js”A Node.js Remote shared by several databases serves one synchronization step at a time. In this release, these calls fail with internal_failure while another database’s sync() publishes through that same object:
open()oropenRevision()with that remoteresolveConflict()- A
sync()that pulls - The remote’s
status()andcollect()
They fail instead of waiting. Nothing is lost, and a retry succeeds after the publication ends. Give each database its own Remote over the same location, or let one database’s sync() finish before another database makes one of these calls.
Automatic outcomes
Section titled “Automatic outcomes”OpenOptions.on_sync reports what the automatic driver did: UpToDate, Pushed, Pulled, Conflict, or Failed carrying the error. The hook runs on the driver’s thread. Transfer the outcome to your program’s thread, as the quickstart does. Do not call the database from the hook.
A program that ignores Failed learns of an unreachable remote only through its accumulating local writes.
Retention
Section titled “Retention”A remote keeps the newest ten revisions and collects the rest once twenty exist. RemoteOptions.retention with RetentionPolicy.bounded changes both bounds. RetentionPolicy.unbounded() keeps every revision until Remote.collect runs collection on demand. The remote page describes that call.
Advancing the floor is irreversible. Every application sharing a namespace must therefore use the same bound. open_revision cannot open a collected revision and reports revision_retired.
Devices behind retention
Section titled “Devices behind retention”A database whose base revision was collected also reports revision_retired for a statement needing an unfetched piece. This can happen after a device stays away longer than the bound. Nothing is lost.
- Call
sync()to adopt the retained head. - If it reports a conflict, call
resolve_conflict(). - Retry the statement.
Automatic synchronization adopts the retained head when it next pulls. Retention also limits how long a device can stay away after a push whose answer it never received. Conflicts explains the consequence.
Interrupted collection
Section titled “Interrupted collection”In this release, collection leaves the head cleaning if any of these happens:
- Its process stops, including during collection that synchronization runs when retention is due.
- It cannot learn whether its opening head swap took effect.
- Storage fails both a sweep step and the closing head swap.
Every sync() then fails with temporarily_unavailable until Remote.recover_abandoned_collection runs. If temporarily_unavailable persists while storage is reachable and no collector runs, call Remote.recover_abandoned_collection once.
Old revisions
Section titled “Old revisions”open_revision pins one retained revision as a read-only snapshot. The functions page describes it. By default it fetches the whole revision, which suits extended work but is expensive for a glance. For a targeted read, pass PrefetchPolicy.disabled() in the snapshot’s connection options. The snapshot then fetches only the pages that the query touches.
Snapshot directories
Section titled “Snapshot directories”Closing a snapshot removes its materialization unless its directory existed beforehand. In this release, an incomplete removal can leave part of that directory if the process or page ends, or removal fails. open_revision at that path then fails with invalid_state.
- Delete the directory.
- Open the revision again.
While the snapshot is open, its directory belongs to the library. Closing removes everything in it, including a database opened inside it.
In the browser in this release, another Worker can add an entry to the snapshot’s directory while the snapshot closes. If the entry’s name is longer than 255 bytes, every database on the page stops answering until the page reloads.
Results
Section titled “Results”Row limits
Section titled “Row limits”ConnectionOptions.maximum_rows bounds one result. A larger result fails with result_limit_exceeded. Its action names the option that raises the bound. A SQL LIMIT with paging provides a lasting solution.
The bound is checked after the statement completes. It limits what reaches your code, rather than the statement’s memory or time. Give a query that could grow without end, such as a recursive CTE or ad hoc query, its own LIMIT.
Reads and writes
Section titled “Reads and writes”Every result carries column names. Read a row by position or name. query accepts one reading statement, and execute accepts one writing statement. The wrong call fails before execution with write_in_query or read_in_execute. Use transaction() for transaction control.
As in SQLite, this decision follows the statement rather than a virtual table’s actions during execution. query accepts a table-valued PRAGMA that acts, such as SELECT * FROM pragma_optimize, and runs that PRAGMA. Whatever it writes becomes a pending write and is published, including the sqlite_stat1 table that optimize creates. Run such a PRAGMA in statement form, PRAGMA optimize, with execute. A snapshot from open_revision refuses it.
Values and encoding
Section titled “Values and encoding”In JavaScript, integers are numbers unless you open the database with readBigInts, as the Node quickstart shows.
potluckdb’s SQLite omits UTF-16 support (SQLITE_OMIT_UTF16), so every database uses UTF-8. PRAGMA encoding returns no row, and pragma_encoding does not exist. SQLite leaves validation of TEXT as UTF-8 to the application.
Keep invalid UTF-8 bytes as a BLOB. Do not convert them to TEXT, for example with CAST(... AS TEXT). In this release, reading such TEXT fails with integrity_failure. A write whose RETURNING clause produces it commits before reporting that failure. A conflict can then quarantine the connection’s later transactions.
Transactions
Section titled “Transactions”One connection writes at a time, as in SQLite. A write meeting another connection’s write transaction waits up to the busy timeout for it to end.
In this release, a write or transaction() on the database object holds the database while waiting. The connection from connect() holding the write lock cannot commit until that wait ends. Its commit is delayed by the whole busy timeout. The database object’s write then fails with temporary database_busy. Nothing is lost, and a retry succeeds. While connections from connect() write, make the application’s other writes through connections, not through the database object.
Python threads and generators
Section titled “Python threads and generators”In Python, enter, use and leave a transaction on one thread. In this release, a transaction can stay open across a yield in a generator. If another thread then resumes, closes or garbage-collects the generator, the transaction’s database or connection becomes unusable for the rest of the process. Every later call on it waits forever. The transaction was never committed.
For example, a thread pool does this with a synchronous generator that Starlette or FastAPI streams. Finish the transaction before the generator yields.
Conflicts
Section titled “Conflicts”A conflict is returned as a value. It does not raise an exception. A conflict means that the device’s push found a remote head that the device had not seen. resolve_conflict() adopts the remote revision and recognizes local transactions already published. It replays pending statements that are safe to replay and returns named lists of replayed, quarantined and pending transactions.
The application decides what to do with quarantined statements. Inspect that list in production instead of assuming it is empty. After you handle them, call clear_quarantine_records to discard the records.
Interrupted resolution
Section titled “Interrupted resolution”If the process ends during resolve_conflict() before the remote revision is installed, the next sync() returns the conflict again. resolve_conflict() then completes it.
If the process ends after installation, the next sync() or resolve_conflict() completes resolution without returning the conflict. In this release, no report from that call names the transactions it replays or quarantines. Quarantined statements still have records, and clear_quarantine_records counts them. To account for every quarantined statement, compare that count with the quarantined statements that your reports named.
Repeated replay after retention
Section titled “Repeated replay after retention”Recognition of a published transaction reaches only as far as retained revisions. A push can reach the remote without its answer reaching the device if the process ends or the connection drops then. The device then still holds the transaction as pending. If it synchronizes before collection removes that revision, resolve_conflict() lists the transaction as already published. Under default retention, other devices can cause collection by publishing between ten and twenty revisions.
A statement setting a value leaves the same result if replayed twice. One that adds does not, such as an INSERT with a SQLite-chosen key or count = count + 1. An insert with an application-chosen key fails on replay. Its transaction is quarantined instead of applied twice. For devices that can stay away across that many revisions, widen retention or choose your own keys.
Errors during replay
Section titled “Errors during replay”Replay repeats each pending transaction statement by statement and checks every error again. It cannot check a statement that SQLite refused before execution, such as a syntax error or missing table. If a transaction caught that error and then committed, a conflict quarantines it.
- Roll back after such an error.
- Run the transaction again.
A statement that failed with SQLITE_BUSY and was retried replays as its retry.
Statements outside transactions that use SQLite’s FAIL handling are the exception to this error check:
INSERT OR FAILUPDATE OR FAIL- A statement meeting a constraint declared
ON CONFLICT FAIL - A statement whose trigger calls
RAISE(FAIL)
SQLite keeps rows such a statement changed before failure. In this release, when one succeeded on the device but fails partway when it is replayed, replay keeps the rows changed before the failure. It does so as SQLite would at that point and reports the transaction as replayed.
For example, another device can publish a row with key 2 before INSERT OR FAIL INTO t VALUES (1, 'a'), (2, 'b') replays. Replay keeps row 1, and the device’s row 2 is gone. Run statements with FAIL handling inside a transaction. Inside a transaction, a statement whose outcome changes on replay makes replay quarantine the whole transaction.
Connection settings after a pull
Section titled “Connection settings after a pull”Replay uses the connection settings under which each pending transaction committed. A pull or conflict resolution replaces every SQLite connection. Each replacement connection keeps the busy timeout it was opened with and these settings:
cache_size, case_sensitive_like, cell_size_check, foreign_keys, ignore_check_constraints, query_only, recursive_triggers, temp_store, trusted_schema.
TEMP tables, indexes, triggers and views, and every other setting, are lost. If your application depends on them, set them again after each pull. Under automatic synchronization, on_sync reports the pull.
SQLite’s per-connection counters restart on the replacement. PRAGMA data_version and total_changes() therefore do not show that a pull changed the database. The sync() outcome and automatic on_sync report a pull as Pulled.
Once a connection creates or drops a TEMP object, replay cannot reproduce that connection’s transactions. After a connection creates or drops a TEMP object, or changes another setting that can change a statement’s result, a conflict quarantines that connection’s transactions instead of replaying them. This continues until the connection is next replaced. Settings that govern only storage, memory and lock waits, such as secure_delete, journal_size_limit and busy_timeout, leave transactions replayable.
Settings changed inside transactions
Section titled “Settings changed inside transactions”SQLite applies a setting when it prepares the statement that changes the setting. This happens before the transaction records that statement. In this release, a transaction changing a kept setting partway through is quarantined on conflict, rather than replayed, in these cases:
- The change goes through the Python, Node.js or browser library.
- The change goes through a JDBC prepared statement.
- A statement changing the setting is described inside the transaction.
query()refuses a statement changing the setting inside the transaction.
PRAGMA defer_foreign_keys is not kept. As in SQLite, it lasts only until the next transaction ends. In this release, setting it before BEGIN or SAVEPOINT applies on the device but does not carry into replay. A transaction relying on it, such as one that inserts a child row before its parent, is quarantined on conflict. Set PRAGMA defer_foreign_keys inside the transaction, after it begins.
Schema changes
Section titled “Schema changes”A schema migration is a transaction like any other. Two devices can migrate the same database before either synchronizes the other’s migration. On conflict, the second migration replays statement by statement against the first device’s schema. Replay does not compare the schema a transaction was written for with the schema it replays on. It quarantines migrations only for the reasons above, such as a changed statement outcome.
- Run each migration and set
PRAGMA user_versioninside one transaction. - Record the version in a table keyed by version before committing that transaction.
For example, use INSERT INTO schema_migrations(version) VALUES (2) with version as the primary key. The second migration copy then fails on that key. Its whole transaction is quarantined, and the device’s later transactions then still replay.
Name columns in an INSERT. A row written under an earlier schema can then replay after a migration adds a column.
A migration split across transactions replays each step against the schema present at replay. A repeatable migration without a version record replays without error. An example is CREATE TABLE IF NOT EXISTS followed by PRAGMA user_version. A device that migrated to version 2 can then set user_version back to 2 after another device reaches version 3.
Errors
Section titled “Errors”Every error carries a code, one of four categories, a message and an action. The errors page lists codes, categories and general actions.
| Category | Program action |
|---|---|
| Caller | Correct the call. |
| Temporary | Retry. |
| Permanent | Change the data or deployment. |
| Internal | Report the error. |
The hook’s Failed outcome carries the same error the call would have raised.
The license key
Section titled “The license key”Without a license key, use is limited to a trial per machine. Before a build machine’s trial ends, supply the license key where the SDK reads it. The licensing page lists the sources.
An application shipping to end users embeds the license key in the license_key open option. After the trial, only creating new databases stops without a license key. Existing databases keep opening and working.
Closing
Section titled “Closing”- Finish transactions before closing a database.
- Close the databases and snapshots that use a remote.
- Close the remote.
Closing the remote first fails with invalid_state while an owner still uses it.
Explicit connection close
Section titled “Explicit connection close”Close every connection explicitly. Do not leave it to garbage collection, a destructor or a finalizer. A connection or database close during another thread’s synchronization fails with temporary database_busy, like any other call. Retry after synchronization ends. Garbage collection, destructors and finalizers cannot retry.
In this release, a connection dropped that way during synchronization stays open for the rest of the process. The database can then no longer be closed. If that connection held an open transaction, other writers fail with database_busy until the process exits. That transaction was never committed.
Dart isolates
Section titled “Dart isolates”Close every database before the Dart isolate that opened it ends. With Isolate.run, close the database inside the computation in a finally block. In this release, an isolate can end while a database with an onSync hook is open. The database’s next automatic synchronization attempt then aborts the whole process.
Node Workers
Section titled “Node Workers”- Close every database and snapshot before the Worker that opened it ends or is terminated.
- Close a
Remote.fromStorageremote after its databases and snapshots.
In Node in this release, a database left open when its Worker ends stays open for the rest of the process. Opening its directory again in that process fails with temporarily_unavailable. A transaction left open in the Worker was never committed.
In this release, terminating a Worker while an automatic synchronization waits on the Worker’s JavaScript storage adapter also leaves that Worker running for the rest of the process. The process no longer exits on its own.
Python interrupts
Section titled “Python interrupts”In Python, a KeyboardInterrupt or another exception raised by a signal handler can arrive between any two steps of a call. Python’s documentation warns that most code, including its standard library, cannot be robust against this.
In this release, an interrupt can arrive while open(), connect() or close() hands control to the native library or returns from it. When it does, that database remains unusable for the rest of the process. Its calls fail. A repeated close() waits until the next interrupt or fails. Opening the directory again in that process fails with temporarily_unavailable. You cannot close that database’s Remote.
Everything committed stays committed. A new process opens the database normally. If your program must continue after Ctrl-C, install a SIGINT handler that records the request instead of raising. Stop at a point your program chooses.
Forked processes
Section titled “Forked processes”As in SQLite, never carry a database from one process into a child created with fork(). In this release, the rule covers the whole library. Once a process uses potluckdb, a child created with fork() and no exec must not use it, even to open another database.
On macOS, that child’s first call terminates it with SIGTRAP. On macOS, a child that only exits can also print a panic message from the library’s exit handler. Start worker processes using potluckdb with spawn, for example Python’s multiprocessing with the spawn start method.