@tonbo / sqlite-ursula
通过复制SQLite数据库 厄休拉 流,适用于Node.js 22.19或更高版本。
import { open } from "@tonbo/sqlite-ursula";
const db = await open("https://streams.example.com/acme/notes");
db.exec("CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY, body TEXT)");
db.prepare("INSERT INTO notes(body) VALUES (?)").run("hello"); // committed on this host
await db.synced(); // in the stream, and this process still owns it
res.end("saved");
唯一的规则: 在进程外的任何东西对您所写或所读的内容(响应、队列确认、对另一个服务的调用)起作用之前, await db.synced().
提交是本地的。扩展将其附加到它拥有的日志中,并在后台将日志复制到流中。 synced() 一旦流保存调用之前所做的每个提交,以及调用之后开始的往返旅行发现该进程仍然是所有者,就会进行解析。它永远不会阻塞事件循环,并且在等待过程中保持进程活动。脚本在退出之前等待它。
| 开发人员 | 会发生什么 |
|---|---|
| 运行语句或COMMIT | 本地工作:SQLite,加上一次写入日志 |
等待 synced() | 写入时一个追加往返,空闲时一个leader读取 |
| 失去了流 | 提交继续, synced() 等待,过去 maxPendingBytes 写入失败 backlog_full |
| 被接管了 | 语句和 synced() 扔 UrsulaResetError (fenced), on("reset") 侦听器运行 |
| 崩溃 | 提交从未确认的流在下次打开时从日志返回并重新发送 |
开放
open(url, { dir, token, signal, maxPendingBytes, recreate, readOnly, ...sqlite }) 返回一个类型的承诺 UrsulaDatabase,一个 node:sqlite DatabaseSync 子类,所以毛毛雨,kysely和Pi的司机取它。剩下的选项是 DatabaseSync.
- 进程中第一次打开的流将其缓存绑定在
dir(在默认情况下<user cache>/sqlite-ursula/0.8),捕获它并声明流:最后到达流的打开者拥有它。稍后打开的进程返回同一绑定上的另一个连接,没有网络。并发打开共享1。工作线程调用open了。 token是承载令牌请求,还是提供分发令牌:看到了吗 令牌.- 该开的时候放弃
signal火灾,30后没有一个。 - 多个连接共享一个绑定及其文件,因此node:sqlite很忙
timeout默认为5000ms。通过timeout去改变它。 - 当open不能保留的本地缓存保存数据时,它被移到一边。在丢失提交已确认的缓存的流的历史更改之后(恢复到较早的点),在缓存保存未记录确认的提交(脱机或在崩溃之前已确认)的任何历史更改之后,当它的db文件不是它自己的并且与
recreate: true当溪流消失的时候。没有保存数据的缓存将被替换为没有移动的缓存。移动的缓存进入salvage/在流的缓存目录中作为一个普通的SQLite数据库manifest.json,status().salvage列出它,然后open从流构建一个新的缓存。没有recreate,在保存数据的缓存上丢失流失败not_found. db.close()是同步的。一旦绑定的最后一个连接关闭,复制将在后台耗尽,绑定将退出。再打开一次open().readOnly: true不要求任何东西,也不保护所有者,并写了失败SQLITE_READONLY(8).在拥有流的进程中,它在所有者的绑定上返回一个只读连接,该连接看到所有者最近的提交,无论是否被确认,所以它synced()像主人一样等待。在任何其他进程中,它建立或捕获自己的读取器缓存(role: "reader",reader-<n>/在流的缓存目录中,每个读取器进程一个)在打开时读取流的尾部,并从那时开始读取流:它不跟随以后的提交,并且它的synced()立即解决。一条封闭的小溪也会打开。在另一个部署上构建的读取器缓存失败wrong_cluster,就像所有者的缓存一样,然后构建一个空缓存。关闭进程的所有只读连接,然后重新打开以读取更新的提交。当流不可达时,它打开一个较早的读取器缓存,否则就等待,直到信号触发。丢失的流失败not_found.readOnly与recreate失败与invalid_argument.
UrsulaDatabase 增加了 name (流的路径,如 /acme/notes), boundPath (本地数据库文件的扩展绑定,其中 status().path 报告及 SQL函数 作为 path), synced({ signal }), status() 和 on("reset", listener) / off("reset", listener).
令牌
token 是一个字符串,还是一个提供者:一个返回的异步函数 { token, expiresAt },在那里 expiresAt 是一个 Date 或者纪元以来的毫秒数。然后,包在后台保持令牌保持新鲜,没有单独的刷新过程。
import { open, type TokenProvider, UrsulaUnavailableError } from "@tonbo/sqlite-ursula";
const token: TokenProvider = async ({ signal }) => {
const r = await fetch(exchangeUrl, { method: "POST", headers: { authorization: `Bearer ${apiKey}` }, signal });
if (r.status === 401) throw new UrsulaUnavailableError("unauthorized: the exchange refused the key", "unauthorized");
if (!r.ok) throw new Error(`the exchange answered ${r.status}`);
const { access_token, expires_in } = await r.json();
return { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
};
const db = await open("https://streams.example.com/acme/notes", { token });
- 当包没有持有有效令牌时,在打开的第一个请求之前调用提供程序,在每个令牌生命周期的70%到80%之后再次调用,当请求被拒绝时再次调用
401。并发调用共享一个。signal当包停止等待答案时触发:30秒后,或者当使用字符串令牌打开时停止了提供者,或者没有数据库使用它。 - 每个呼叫都是手动的
previous,当包不再接受它时,提供程序最后分发的令牌,与它分发的令牌完全相同:在计划刷新时的当前令牌,或服务器在执行了一个请求后拒绝的令牌401. 它是undefined在第一次调用之前和提供者停止之后,除了open的绑定被拒绝的令牌,或者仍然绑定并阻塞的数据库被拒绝的令牌之外,其余的令牌都保留previous在一个停止过程中,直到提供者发出另一个或一个带有字符串令牌的open停止提供者。当previous,分发一个不同的令牌,并重新填充保存它的缓存。扩展使用相等的令牌表示没有更改,因此答案等于previous,与没有周围空格的情况相比,将被视为失败的调用。返回失败的调用,并在失效时重试previous因此,最迟,将其令牌交还直到其过期的缓存将在其移交时再次被请求。从那个到期开始,只有失败,因为它计数,所以后退再次开始在1秒。永远不会改变的标记属于字符串token或在URSULA_VFS_TOKEN_FILE,而不是在供应商。 - 打开程序立即获得包持有的有效令牌,因此已经绑定的数据库上的连接不需要网络,并且在后台运行到期的刷新。过期令牌仅在提供程序失败时使用。否则,open等待飞行中的呼叫或新的呼叫,因为在长时间暂停该过程之后。
- 哪个函数生成持有的令牌并不重要,因为进程携带一个令牌。带有另一个函数的open(包括内联箭头)共享持有的令牌和正在执行的调用,并为下一个调用运行的函数命名。
- 抛出错误的提供程序
action是fix永远失败了,不管它的类是什么:一个打开等待它的令牌拒绝,并且包停止调用提供程序,直到下一个open()有一个提供者。一个UrsulaError是拒绝本身。任何其他错误,例如无法扩展此包的类的库之一,都是错误cause一个UrsulaUnavailableErrorunauthorized,行动fix。所有其他错误都将使用后退重试,从1秒到30秒,而持有的令牌将继续服务。在1秒内到期的令牌也是如此,这使得没有时间刷新它。 - 当一个空位
signal在等待提供程序时触发,然后拒绝UrsulaUnavailableErrorunavailable(行动retry),其cause是提供程序的最后一个错误,或者是提供程序没有应答的信号原因。非可打印的ASCII符号,不带空格或expiresAt那不是一个时间,失败的开放与UrsulaSetupErrorinvalid_argument。也一样。expiresAt小于10^12,读取为epoch后的秒数(JWT的)exp)而不是毫秒。 - 被拒绝的捆绑
401失败unauthorized当它signal触发,就像使用字符串标记一样。扩展报告一个401只针对已经绑定的数据库,所以提供者只有在那时才知道:被拒绝的令牌被丢弃,调用开始,下一个打开等待新的令牌。当提供者的最后一次调用失败时,该失败是错误的cause,它的作用是retry除非提供者永远失败了。 - 被拒绝两次的绑定数据库请求将阻止复制
unauthorized,并立即调用提供程序。当设置新令牌时,扩展会立即再次发送被阻止的请求。当提供者交还被拒绝的令牌复制时,一旦报告拒绝的每个数据库被关闭和退役,每个提交都被确认,或者不再被阻止,包就会再次接收它。然后立即运行已计划的重试。 - 打开后提供程序失败,令牌保持不变。一旦服务器拒绝,
status().replication.condition是unauthorized,并继续提交,直到积压被填满。 - 令牌是每个进程一个。命名令牌的最后一个打开设置它。带有字符串的打开将提供者停止在其线程上,而在字符串下等待它的打开继续进行。一个函数存在于一个JavaScript线程中,因此每个线程运行自己的提供程序,该提供程序在每次刷新时通过另一个线程设置字符串设置其令牌:为进程的线程提供相同类型的令牌。
- 只有在open等待令牌时,提供程序的计时器才使进程保持活动状态。一旦它打开的线程没有绑定数据库,提供程序就会停止,无论打开的令牌是什么,因为它们都携带进程的令牌。
- 普通字符串记号和
URSULA_VFS_TOKEN_FILE像以前一样工作。没有令牌被记录。
控制输出
gate(db, work, { signal }) 运行 work 并且只接受它的结果一次 synced() 之后打的电话 work 解决解决。它将一条规则应用于任何对结果起作用的东西:一个fetch风格的处理程序 Response队列框架确认的作业处理器的承诺,您自己确认的消息。
import { gate, open } from "@tonbo/sqlite-ursula";
const db = await open("https://streams.example.com/acme/notes");
// A fetch-style handler: the status, the headers and every body chunk wait.
const handler = (request: Request) => gate(db, () => router.fetch(request), { signal: request.signal });
// A queue you acknowledge yourself.
for await (const message of subscription) {
await gate(db, () => applyMessage(db, message)); // records message.id in the same transaction
await message.ack(); // a crash before this redelivers, and applyMessage skips an id it has seen
}
db是一个数据库或一个数据库数组。如果有几个,大门会同时等待所有的人。- 读取也会等待:结果可能已经读取了另一个调用的挂起提交,或者该进程即将被接管。
work的错误和它的结果一样等待,除了UrsulaError,当库将其包装(作为cause,或在AggregateError):被拒绝的写没有提交任何内容,等待将报告重置。在等待期间的重置拒绝使用UrsulaResetError,结果被丢弃。- 当
work返回一个Response有了消息体,gate就会用一个相同的状态和报头进行解析,这些状态和报头的消息体只会传递一次每个数据块及其结束synced()读取数据块后进行的调用。一个调用覆盖了在前一个调用运行时读取的每个数据块,因此一个流每批支付一次往返,而不是每个数据块,并且gate最多提前读取64 KiB加上一个数据块。重置使主体出错。 signal限制等待结果的时间,这对于Response它的状态和标题是:门拒绝与signal.reason并取消返回的主体,提交继续复制。它不会缩短一个已经开始的身体。取消主体来阻止它。- 一、
node:http响应本身不是门控的:将其写在门控之后,如res.end(await gate(db, () => save(req))).
大门没有覆盖的:
- 影响内
work在它返回之前,例如出站调用或处理程序中间的手动确认。await db.synced()在他们面前。 - WebSocket帧、原始套接字、定时器、后台作业和子进程。
- 只有一次的效果。在发布之后但在确认之前再次运行工作的崩溃,因此在与工作相同的事务中记录消息id和幂等键。
- 任何超出
synced()它本身的意思是:流持有提交,具有服务器提供的持久性。
进口
importSqlite(url, file, { token, timeoutMs }) 将一个普通的SQLite数据库文件导入到一个新的流中,作为流的第一个快照,然后返回 { offset, digest, pages, bytes }. 然后 open(url) 它和其他任何溪流一样。
token是一个字符串,仅用于导入。import阻塞了它的线程,因此没有提供者可以在此期间刷新令牌:pass(await provider({ signal })).token.- 文件必须有4096字节的页面,没有
-wal旁边没有热日记。VACUUM INTO写这样一个副本(之后)PRAGMA page_size = 4096当数据库有其他页面时)。文件只被读取。 - 流必须是缺失的,或者是空的。其他内容类型的流失败
stream_format还有其他的吗?stream_state,什么也没有发表。另一个发布行的服务器出现故障protocol_mismatch的回答Ursula-Protocol和Ursula-Cluster-Id与no_server_identity. - 导入运行时,流的打开失败
stream_state。在声明流离开拒绝打开和导入的流后失败的导入stream_state直到它被删除或重新创建。UrsulaUnavailableError从一个输入中说fix因为这个原因。 - 超过请求限制的快照将分成几个部分,这需要服务器上的冷存储。如果没有,则压缩快照超过限制的文件将失败
invalid_argument,命名极限。 - 调用阻塞它的线程,直到导入完成为止
timeoutMs(默认为10分钟)。
错误
包因扩展失败而抛出的每个错误都有一个 kind,一个 action (retry, reopen, stop 或者 fix)和a cause。分支 action。网络故障从不抛出from语句或 synced().
| 类 | 抛出的 | 类型 |
|---|---|---|
UrsulaWriteRefusedError | 写或提交,没有任何承诺 | backlog_full (后重试 synced()), unsupported (修复) too_large (修复:提交的页面占用超过 URSULA_VFS_MAX_COMMIT_BYTES,默认为128 MiB,因此拆分事务或提高限制,最多1 GiB)。 |
UrsulaResetError | 重置后的语句, synced(), reset 听众 | fenced, stale, rewound, deleted, local_damage, stream_state, internal |
UrsulaUnavailableError | open() 当它的信号首先触发时,也是在它等待令牌提供者(unavailable,行动 retry) | 最后一个条件(unreachable, unavailable, rate_limited, unauthorized, payment_required, forbidden),或 cancelled 当它还没有遇到任何人的时候 |
UrsulaSetupError | open(), importSqlite(), synced() 或者 status() 一个已经退役的数据库 | invalid_url, invalid_argument, not_found, in_use, bound_elsewhere, local_setup, stream_format, no_server_identity, protocol_mismatch, wrong_cluster, stream_state, claim_races, not_bound, internal |
一个 UrsulaSetupError 说 fix,除了 in_use 和 claim_races,上面说 retry, internal,上面写着 reopen。一个开放的最后条件是 wrong_cluster, no_server_identity 或者 protocol_mismatch 当它发出信号时抛出一个 UrsulaSetupError 是那种,不是那种 UrsulaUnavailableError。满足其中一个条件的绑定数据库将其报告为 status().replication.condition,无需重置,并在路由或服务器固定后继续运行。
一、 stream_state 重置(fix)意味着流不再匹配所有者所写的内容:另一个编写者附加到它,有人关闭了它,或者服务器丢失了它已确认的生产者状态。日志将保留未确认的提交,并且将拥有流的打开失败 stream_state 当水流保持这种状态时。
重置后,待定 synced() 电话先被拒绝,然后是 reset 侦听器运行一次。数据库的每个语句都会抛出重置,包括读操作,但它打开的读事务中的读操作除外,后者保留其快照。写入仍然失败。 open() 再次流,或停止时的动作是 stop。新的打开不需要等待旧数据库关闭:它在本地缓存的副本上工作,该副本包含旧数据库所做的每一次提交。当您不再需要旧数据库时,关闭它。
π耐用
在厄休拉流中持久化一个圆周率的线束,使用圆周率自己的习惯,只交换存储空间。 @tonbo/sqlite-ursula/pi Pi Durable的官方节点运行 SqliteStorage 在流上的URL:
import { Harness } from "@earendil-works/pi-durable";
import { openUrsulaPiStorage } from "@tonbo/sqlite-ursula/pi";
const storage = await openUrsulaPiStorage("https://streams.example.com/acme/agent", { closeTimeoutMs: 10_000 });
const harness = await Harness.open(storage, { models, registry }, context);
// ...
await harness.close(context); // closes the storage too
harness.close(context) 关闭线束打开的存储,在Pi 1.0到1.5上,因此存储不需要关闭它自己。没有 dir 它使用默认的缓存根。
每个事务和语句只有在 synced(),因此,一旦流持有Pi提交,它就会被解析。被拒绝的承诺 backlog_full 提交。它从1.2开始等待 synced() 并再次运行,因为Pi期望存储重试自己的瞬态失败:在Pi 1.5上,它将一组提交存储在一个中 Storage.commit 调用,整个调用以相同的批再次运行。在Pi 1.0和1.1上,它会抛出Pi StorageRejected,所以会议继续进行。重置会抛出 UrsulaResetError 它自己,所以会话停止。 close 等待到 closeTimeoutMs 用于 synced(). @earendil-works/pi-durable 是一个可选的对等依赖,从1.0.0到但不包括1.6.0,和 /pi 条目是唯一导入它的条目。
Pi 1.4在打开SQLite存储时会将其迁移到其模式版本2,而Pi 1.3或更早版本无法打开已迁移的SQLite存储。复制数据库也不例外:迁移与其他任何迁移一样是提交,因此一旦Pi 1.4或更新的所有者打开流,每个后续所有者(包括从流构建的新缓存)都需要Pi 1.4或更新的数据库。只将部署回滚到Pi 1.4之后的新流上。
openUrsulaPiStorage(url, options) 通过 dir, token, signal, maxPendingBytes 和 recreate 来 open,没有其他选择 open. walAutoCheckpointPages (SQLite的WAL自动检查点阈值) busyTimeoutMs (语句等待锁的时间)转到Pi的节点驱动程序,该驱动程序在数据库上打开自己的连接,并将Pi的默认值应用于您遗漏的那些。 closeTimeoutMs 默认是10秒。
SQL函数
其他主机在连接上调用扩展的SQL函数 loadUrsulaVfs() 返回: ursula_open, ursula_synced, ursula_events, ursula_cancel, ursula_status, ursula_stats, ursula_set_token, ursula_import(url, file, options_json, timeout_ms),他的 timeout_ms 必须是正的,然后呢 ursula_classify(message, errcode, path),它表示SQLite的消息和结果代码中的失败是什么 {kind, action, class, message, fix, request_id}:其 kind, action 和 class (write_refused, reset, unavailable 或者 setup(此包抛出的错误)、 message 一个主持人为它表演,然后 fix 和 request_id,这是 null 对于每次扩展失败。它返回 null 为SQLite自己的错误。 path 是语句运行的数据库,其状态命名语句的I/O错误,它本身没有说明什么。通过 NULL 查询SQL函数的错误。一个宿主重新打开 ursula_open 关闭后:一旦一个数据库退役,一个平原 new DatabaseSync(path) 它的道路被拒绝,直到 ursula_open 再次绑定。 ursula_stats(path) 消耗扩展保留的每次提交度量(字节、页面、日志和VFS中的时间),每次消耗之间每种最多8,192个,并报告它减少了什么。从这个包,叫它 db.boundPath 在连接上 loadUrsulaVfs() 的回报。
缓存的文件不是扩展名之外的SQLite文件。db文件保存了一个标记来代替SQLite的头魔术,它的WAL是 db-uwal。这个 sqlite3 Shell, node:sqlite没有扩展,并且在加载扩展之前打开的连接失败 SQLITE_NOTADB (file is not a database),什么也改变不了。要使用普通SQLite读取数据库,请从连接中将其导出 open() 返回, VACUUM INTO.
0.8服务器和流不读取0.7数据,0.8不读取0.7缓存。
平台
这个扩展是预先构建的:npm会为你的平台安装这个构建。 @tonbo/sqlite-ursula-<platform>,作为可选依赖项。它是预先构建的:
- Linux x64、arm64, glibc 2.28及以上版本,如Debian 11、Ubuntu 20.04、Amazon Linux 2023、RHEL 8。
- Linux x64带肌肉,如Alpine。
- macOS 11或更高版本的苹果芯片。
在任何其他平台上,自己构建扩展并设置 SQLITE_URSULA_VFS。该扩展拒绝加载到3.50.4以上的SQLite中。
SQLITE_URSULA_VFS
当 SQLITE_URSULA_VFS 设置时,包将在该路径加载库,而不是在预构建的路径加载库。传递路径给 loadUrsulaVfs(path) 在其他调用做同样的事情之前。要从源代码(Rust)构建扩展:
git clone https://github.com/tonbo-io/ursula
cd ursula/clients/sqlite-vfs
cargo build --release
export SQLITE_URSULA_VFS=$PWD/target/release/libsqlite_ursula_vfs.so # .dylib on macOS
许可证
apache - 2.0