Skip to content

Commit eb7dadd

Browse files
authored
libsql: attach databases from other namespaces as readonly (#784)
* libsql: attach databases from other namespaces as readonly With this proof-of-concept patch, other namespaces hosted on the same sqld machine can now be attached in readonly mode, so that users can read from other databases when connected to a particular one. * connection: add allow_attach to config Default is false, which means connections are blocked from attaching databases. If allowed, colocated databases can be attached in readonly mode. Example: → attach another as another; select * from another.sqlite_master; TYPE NAME TBL NAME ROOTPAGE SQL table t3 t3 2 CREATE TABLE t3(id) * libsql,namespaces: add client-side ATTACH support * attach: support ATTACH x AS y aliasing We're going to need it, because the internal database names in sqld are uuids, and we don't expect users to know or use them. * attach: fix quoted db names In libsql-server, raw db names are uuids that need to be quoted, so that needs to be supported in the ATTACH layer. As a bonus, "names" that are actually file system paths are refused to prevent abuse. * libsql-server: drop stray serde(default) from allow_attach * libsql-replication: update proto files * libsql-replication: regenerate protobuf * tests: move attach to its own test * libsql-replication: fix proto number after rebase
1 parent dc53a4d commit eb7dadd

File tree

9 files changed

+156
-4
lines changed

9 files changed

+156
-4
lines changed

libsql-replication/proto/metadata.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ message DatabaseConfig {
1515
optional string bottomless_db_id = 6;
1616
optional string jwt_key = 7;
1717
optional uint64 txn_timeout_s = 8;
18+
bool allow_attach = 9;
1819
}

libsql-replication/src/generated/metadata.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ pub struct DatabaseConfig {
2121
pub jwt_key: ::core::option::Option<::prost::alloc::string::String>,
2222
#[prost(uint64, optional, tag = "8")]
2323
pub txn_timeout_s: ::core::option::Option<u64>,
24+
#[prost(bool, tag = "9")]
25+
pub allow_attach: bool,
2426
}

libsql-server/src/connection/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub struct DatabaseConfig {
1818
pub bottomless_db_id: Option<String>,
1919
pub jwt_key: Option<String>,
2020
pub txn_timeout: Option<Duration>,
21+
pub allow_attach: bool,
2122
}
2223

2324
const fn default_max_size() -> u64 {
@@ -35,6 +36,7 @@ impl Default for DatabaseConfig {
3536
bottomless_db_id: None,
3637
jwt_key: None,
3738
txn_timeout: Some(TXN_TIMEOUT),
39+
allow_attach: false,
3840
}
3941
}
4042
}
@@ -50,6 +52,7 @@ impl From<&metadata::DatabaseConfig> for DatabaseConfig {
5052
bottomless_db_id: value.bottomless_db_id.clone(),
5153
jwt_key: value.jwt_key.clone(),
5254
txn_timeout: value.txn_timeout_s.map(Duration::from_secs),
55+
allow_attach: value.allow_attach,
5356
}
5457
}
5558
}
@@ -65,6 +68,7 @@ impl From<&DatabaseConfig> for metadata::DatabaseConfig {
6568
bottomless_db_id: value.bottomless_db_id.clone(),
6669
jwt_key: value.jwt_key.clone(),
6770
txn_timeout_s: value.txn_timeout.map(|d| d.as_secs()),
71+
allow_attach: value.allow_attach,
6872
}
6973
}
7074
}

libsql-server/src/connection/libsql.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,31 @@ impl<W: Wal> Connection<W> {
769769
Ok(enabled)
770770
}
771771

772+
fn prepare_attach_query(&self, attached: &str, attached_alias: &str) -> Result<String> {
773+
let attached = attached.strip_prefix('"').unwrap_or(attached);
774+
let attached = attached.strip_suffix('"').unwrap_or(attached);
775+
if attached.contains('/') {
776+
return Err(Error::Internal(format!(
777+
"Invalid attached database name: {:?}",
778+
attached
779+
)));
780+
}
781+
let path = PathBuf::from(self.conn.path().unwrap_or("."));
782+
let dbs_path = path
783+
.parent()
784+
.unwrap_or_else(|| std::path::Path::new(".."))
785+
.parent()
786+
.unwrap_or_else(|| std::path::Path::new(".."))
787+
.canonicalize()
788+
.unwrap_or_else(|_| std::path::PathBuf::from(".."));
789+
let query = format!(
790+
"ATTACH DATABASE 'file:{}?mode=ro' AS \"{attached_alias}\"",
791+
dbs_path.join(attached).join("data").display()
792+
);
793+
tracing::trace!("ATTACH rewritten to: {query}");
794+
Ok(query)
795+
}
796+
772797
fn execute_query(
773798
&self,
774799
query: &Query,
@@ -785,12 +810,29 @@ impl<W: Wal> Connection<W> {
785810
StmtKind::Read | StmtKind::TxnBegin | StmtKind::Other => config.block_reads,
786811
StmtKind::Write => config.block_reads || config.block_writes,
787812
StmtKind::TxnEnd | StmtKind::Release | StmtKind::Savepoint => false,
813+
StmtKind::Attach | StmtKind::Detach => !config.allow_attach,
788814
};
789815
if blocked {
790816
return Err(Error::Blocked(config.block_reason.clone()));
791817
}
792818

793-
let mut stmt = self.conn.prepare(&query.stmt.stmt)?;
819+
let mut stmt = if matches!(query.stmt.kind, StmtKind::Attach) {
820+
match &query.stmt.attach_info {
821+
Some((attached, attached_alias)) => {
822+
let query = self.prepare_attach_query(attached, attached_alias)?;
823+
self.conn.prepare(&query)?
824+
}
825+
None => {
826+
return Err(Error::Internal(format!(
827+
"Failed to ATTACH: {:?}",
828+
query.stmt.attach_info
829+
)))
830+
}
831+
}
832+
} else {
833+
self.conn.prepare(&query.stmt.stmt)?
834+
};
835+
794836
if stmt.readonly() {
795837
READ_QUERY_COUNT.increment(1);
796838
} else {

libsql-server/src/http/admin/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ async fn handle_get_config<M: MakeNamespace, C: Connector>(
192192
max_db_size: Some(max_db_size),
193193
heartbeat_url: config.heartbeat_url.clone().map(|u| u.into()),
194194
jwt_key: config.jwt_key.clone(),
195+
allow_attach: config.allow_attach,
195196
};
196197

197198
Ok(Json(resp))
@@ -236,6 +237,8 @@ struct HttpDatabaseConfig {
236237
heartbeat_url: Option<String>,
237238
#[serde(default)]
238239
jwt_key: Option<String>,
240+
#[serde(default)]
241+
allow_attach: bool,
239242
}
240243

241244
async fn handle_post_config<M: MakeNamespace, C>(
@@ -255,6 +258,7 @@ async fn handle_post_config<M: MakeNamespace, C>(
255258
config.block_reads = req.block_reads;
256259
config.block_writes = req.block_writes;
257260
config.block_reason = req.block_reason;
261+
config.allow_attach = req.allow_attach;
258262
if let Some(size) = req.max_db_size {
259263
config.max_db_pages = size.as_u64() / LIBSQL_PAGE_SIZE;
260264
}

libsql-server/src/query_analysis.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::borrow::Cow;
22

33
use anyhow::Result;
44
use fallible_iterator::FallibleIterator;
5-
use sqlite3_parser::ast::{Cmd, PragmaBody, QualifiedName, Stmt};
5+
use sqlite3_parser::ast::{Cmd, Expr, Id, PragmaBody, QualifiedName, Stmt};
66
use sqlite3_parser::lexer::sql::{Parser, ParserError};
77

88
/// A group of statements to be executed together.
@@ -13,6 +13,8 @@ pub struct Statement {
1313
/// Is the statement an INSERT, UPDATE or DELETE?
1414
pub is_iud: bool,
1515
pub is_insert: bool,
16+
// Optional id and alias associated with the statement (used for attach/detach)
17+
pub attach_info: Option<(String, String)>,
1618
}
1719

1820
impl Default for Statement {
@@ -32,6 +34,8 @@ pub enum StmtKind {
3234
Write,
3335
Savepoint,
3436
Release,
37+
Attach,
38+
Detach,
3539
Other,
3640
}
3741

@@ -115,6 +119,8 @@ impl StmtKind {
115119
savepoint_name: Some(_),
116120
..
117121
}) => Some(Self::Release),
122+
Cmd::Stmt(Stmt::Attach { .. }) => Some(Self::Attach),
123+
Cmd::Stmt(Stmt::Detach(_)) => Some(Self::Detach),
118124
_ => None,
119125
}
120126
}
@@ -246,6 +252,7 @@ impl Statement {
246252
kind: StmtKind::Read,
247253
is_iud: false,
248254
is_insert: false,
255+
attach_info: None,
249256
}
250257
}
251258

@@ -267,6 +274,7 @@ impl Statement {
267274
kind,
268275
is_iud: false,
269276
is_insert: false,
277+
attach_info: None,
270278
});
271279
}
272280
}
@@ -277,11 +285,20 @@ impl Statement {
277285
);
278286
let is_insert = matches!(c, Cmd::Stmt(Stmt::Insert { .. }));
279287

288+
let attach_info = match &c {
289+
Cmd::Stmt(Stmt::Attach {
290+
expr: Expr::Id(Id(expr)),
291+
db_name: Expr::Id(Id(name)),
292+
..
293+
}) => Some((expr.clone(), name.clone())),
294+
_ => None,
295+
};
280296
Ok(Statement {
281297
stmt: c.to_string(),
282298
kind,
283299
is_iud,
284300
is_insert,
301+
attach_info,
285302
})
286303
}
287304
// The parser needs to be boxed because it's large, and you don't want it on the stack.

libsql-server/tests/namespaces/meta.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,77 @@ fn meta_store() {
142142

143143
sim.run().unwrap();
144144
}
145+
146+
#[test]
147+
fn meta_attach() {
148+
let mut sim = Builder::new().build();
149+
let tmp = tempdir().unwrap();
150+
make_primary(&mut sim, tmp.path().to_path_buf());
151+
152+
sim.client("client", async {
153+
let client = Client::new();
154+
155+
// STEP 1: create namespace and check that it can be read from
156+
client
157+
.post(
158+
"http://primary:9090/v1/namespaces/foo/create",
159+
json!({
160+
"max_db_size": "5mb"
161+
}),
162+
)
163+
.await?;
164+
165+
{
166+
let foo = Database::open_remote_with_connector(
167+
"http://foo.primary:8080",
168+
"",
169+
TurmoilConnector,
170+
)?;
171+
let foo_conn = foo.connect()?;
172+
173+
foo_conn.execute("select 1", ()).await.unwrap();
174+
}
175+
176+
// STEP 2: try attaching a database
177+
{
178+
let foo = Database::open_remote_with_connector(
179+
"http://foo.primary:8080",
180+
"",
181+
TurmoilConnector,
182+
)?;
183+
let foo_conn = foo.connect()?;
184+
185+
foo_conn.execute("attach foo as foo", ()).await.unwrap_err();
186+
}
187+
188+
// STEP 3: update config to allow attaching databases
189+
client
190+
.post(
191+
"http://primary:9090/v1/namespaces/foo/config",
192+
json!({
193+
"block_reads": false,
194+
"block_writes": false,
195+
"allow_attach": true,
196+
}),
197+
)
198+
.await?;
199+
200+
{
201+
let foo = Database::open_remote_with_connector(
202+
"http://foo.primary:8080",
203+
"",
204+
TurmoilConnector,
205+
)?;
206+
let foo_conn = foo.connect()?;
207+
208+
foo_conn
209+
.execute_batch("attach foo as foo; select * from foo.sqlite_master")
210+
.await
211+
.unwrap();
212+
}
213+
214+
Ok(())
215+
});
216+
217+
sim.run().unwrap();
218+
}

libsql/src/parser.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use crate::{Error, Result};
44
use fallible_iterator::FallibleIterator;
5-
use sqlite3_parser::ast::{Cmd, PragmaBody, QualifiedName, Stmt, TransactionType};
5+
use sqlite3_parser::ast::{Cmd, PragmaBody, QualifiedName, Stmt, TransactionType, Expr, Id};
66
use sqlite3_parser::lexer::sql::{Parser, ParserError};
77

88
/// A group of statements to be executed together.
@@ -30,6 +30,8 @@ pub enum StmtKind {
3030
Write,
3131
Savepoint,
3232
Release,
33+
Attach,
34+
Detach,
3335
Other,
3436
}
3537

@@ -116,6 +118,12 @@ impl StmtKind {
116118
savepoint_name: Some(_),
117119
..
118120
}) => Some(Self::Release),
121+
Cmd::Stmt(Stmt::Attach {
122+
expr: Expr::Id(Id(expr)),
123+
db_name: Expr::Id(Id(name)),
124+
..
125+
}) if expr == name => Some(Self::Attach),
126+
Cmd::Stmt(Stmt::Detach(_)) => Some(Self::Detach),
119127
_ => None,
120128
}
121129
}

libsql/src/replication/connection.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ impl State {
6666
(State::Txn, StmtKind::Release) => State::Txn,
6767
(_, StmtKind::Release) => State::Invalid,
6868

69-
(state, StmtKind::Other | StmtKind::Write | StmtKind::Read) => state,
69+
(state, StmtKind::Other | StmtKind::Write | StmtKind::Read | StmtKind::Attach | StmtKind::Detach) => state,
7070
(State::Invalid, _) => State::Invalid,
7171

7272
(State::Init, StmtKind::TxnBegin) => State::Txn,

0 commit comments

Comments
 (0)