Skip to content

Commit ef8b599

Browse files
committed
fix(tenant): replace count-based id allocation with a durable high-water-mark
The previous allocator derived a new tenant id from the number of active tenants. Two back-to-back CREATE TENANTs with no intervening traffic both computed id 1, causing the second insert to silently overwrite the first. A dropped tenant also freed its slot for reuse, allowing later tenants to inherit the dropped id and pick up any residual permissions or quota rows tied to it. Introduce a dedicated redb table (tenant_id_hwm) that records the highest tenant id ever written. The counter is bumped in the same transaction as the catalog row insertion, so the high-water-mark and the catalog stay consistent under crashes and Raft-replicated applies alike. Dropped ids are never reissued because the counter only moves forward. The in-memory TenantIsolation path (no-catalog / unit-test) gains a matching id_hwm field that shadows the durable counter, and a new allocate_tenant_id() method that respects the reserved system (0) and bootstrap (1) slots, ensuring auto-allocated ids always start at 2. CREATE TENANT routing is updated to prefer the durable catalog allocator when one is wired up, and fall back to the in-memory mirror otherwise. The default_admin_username helper is extracted to a shared function so the DROP TENANT path can reference it without duplicating the convention.
1 parent 44f8d01 commit ef8b599

5 files changed

Lines changed: 236 additions & 14 deletions

File tree

nodedb/src/control/security/catalog/metadata.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
//! Metadata counter and tenant operations for the system catalog.
44
5+
use redb::ReadableTable;
6+
7+
use super::tenant_id_hwm::{HWM_KEY, TENANT_ID_HWM};
58
use super::types::{METADATA, StoredTenant, SystemCatalog, TENANTS, catalog_err};
69

710
impl SystemCatalog {
@@ -70,6 +73,24 @@ impl SystemCatalog {
7073
.insert(key.as_str(), bytes.as_slice())
7174
.map_err(|e| catalog_err("insert tenant", e))?;
7275
}
76+
// Advance the durable tenant-id high-water-mark in the same
77+
// transaction so the id is never reissued by a later
78+
// auto-allocation — covers explicitly-chosen ids and ids
79+
// replicated from a leader on follower-side applies alike.
80+
{
81+
let mut hwm = write_txn
82+
.open_table(TENANT_ID_HWM)
83+
.map_err(|e| catalog_err("open tenant_id_hwm", e))?;
84+
let cur = hwm
85+
.get(HWM_KEY)
86+
.map_err(|e| catalog_err("get tenant_id_hwm", e))?
87+
.map(|v| v.value())
88+
.unwrap_or(0);
89+
if tenant.tenant_id > cur {
90+
hwm.insert(HWM_KEY, tenant.tenant_id)
91+
.map_err(|e| catalog_err("insert tenant_id_hwm", e))?;
92+
}
93+
}
7394
write_txn.commit().map_err(|e| catalog_err("commit", e))
7495
}
7596

nodedb/src/control/security/catalog/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub mod surrogate_pk;
5050
pub mod synonym_groups;
5151
pub mod system_catalog;
5252
pub mod tables;
53+
pub mod tenant_id_hwm;
5354
pub mod tenant_quotas;
5455
pub mod topics;
5556
pub mod trigger_types;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Durable tenant-id allocator for the `_system.tenant_id_hwm` table.
4+
//!
5+
//! Singleton table — one row keyed `"global"` holding the highest
6+
//! tenant id ever allocated. The high-water-mark is monotonic: it is
7+
//! advanced on every tenant write and never lowered by `DROP TENANT`,
8+
//! so a tenant id is never reused for a different tenant across a drop
9+
//! or a restart. This is the same durable-counter idiom the global
10+
//! surrogate identity uses; see [`super::surrogate_hwm`].
11+
//!
12+
//! Auto-allocation derives the next id from the max of this counter
13+
//! and the ids actually present in `_system.tenants`, so a database
14+
//! whose tenants predate the counter self-heals on first allocation
15+
//! instead of colliding with an existing tenant.
16+
17+
use redb::ReadableTable;
18+
19+
use super::types::{SystemCatalog, TENANTS, catalog_err};
20+
21+
/// Redb table: singleton `"global"` -> highest allocated tenant id (`u64`).
22+
pub(super) const TENANT_ID_HWM: redb::TableDefinition<&str, u64> =
23+
redb::TableDefinition::new("_system.tenant_id_hwm");
24+
25+
/// Singleton row key.
26+
pub(super) const HWM_KEY: &str = "global";
27+
28+
/// Lowest id handed to an auto-allocated tenant. `0` is the system
29+
/// tenant and `1` is the bootstrap/default tenant; user tenants start
30+
/// at `2` so neither reserved slot is ever handed out by allocation.
31+
pub(crate) const FIRST_USER_TENANT_ID: u64 = 2;
32+
33+
impl SystemCatalog {
34+
/// Atomically allocate the next tenant id.
35+
///
36+
/// Durable and monotonic: the returned id is strictly greater than
37+
/// every id previously allocated or currently stored, and the
38+
/// counter is never reused after a `DROP TENANT`. Self-heals on
39+
/// first use by taking the max of the stored hwm and the highest id
40+
/// in `_system.tenants`, so tenants created before this counter
41+
/// existed can never collide with a fresh allocation.
42+
pub fn allocate_tenant_id(&self) -> crate::Result<u64> {
43+
let write_txn = self
44+
.db
45+
.begin_write()
46+
.map_err(|e| catalog_err("tenant_id_hwm write txn", e))?;
47+
let next;
48+
{
49+
// Highest id currently materialised in the tenants table.
50+
let mut max_existing = 0u64;
51+
{
52+
let tenants = write_txn
53+
.open_table(TENANTS)
54+
.map_err(|e| catalog_err("open tenants", e))?;
55+
for entry in tenants
56+
.range::<&str>(..)
57+
.map_err(|e| catalog_err("range tenants", e))?
58+
{
59+
let (key, _) = entry.map_err(|e| catalog_err("read tenant", e))?;
60+
if let Ok(id) = key.value().parse::<u64>() {
61+
max_existing = max_existing.max(id);
62+
}
63+
}
64+
}
65+
let mut hwm = write_txn
66+
.open_table(TENANT_ID_HWM)
67+
.map_err(|e| catalog_err("open tenant_id_hwm", e))?;
68+
let stored = hwm
69+
.get(HWM_KEY)
70+
.map_err(|e| catalog_err("get tenant_id_hwm", e))?
71+
.map(|v| v.value())
72+
.unwrap_or(0);
73+
next = stored.max(max_existing).max(FIRST_USER_TENANT_ID - 1) + 1;
74+
hwm.insert(HWM_KEY, next)
75+
.map_err(|e| catalog_err("insert tenant_id_hwm", e))?;
76+
}
77+
write_txn
78+
.commit()
79+
.map_err(|e| catalog_err("tenant_id_hwm commit", e))?;
80+
Ok(next)
81+
}
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use crate::control::security::catalog::StoredTenant;
88+
89+
fn open() -> (tempfile::TempDir, SystemCatalog) {
90+
let dir = tempfile::tempdir().unwrap();
91+
let path = dir.path().join("system.redb");
92+
let catalog = SystemCatalog::open(&path).unwrap();
93+
(dir, catalog)
94+
}
95+
96+
#[test]
97+
fn first_allocation_skips_reserved_slots() {
98+
let (_dir, catalog) = open();
99+
assert_eq!(catalog.allocate_tenant_id().unwrap(), FIRST_USER_TENANT_ID);
100+
}
101+
102+
#[test]
103+
fn allocations_are_strictly_increasing_and_distinct() {
104+
let (_dir, catalog) = open();
105+
let a = catalog.allocate_tenant_id().unwrap();
106+
let b = catalog.allocate_tenant_id().unwrap();
107+
let c = catalog.allocate_tenant_id().unwrap();
108+
assert!(a < b && b < c, "{a} {b} {c}");
109+
}
110+
111+
#[test]
112+
fn never_reuses_id_after_delete() {
113+
let (_dir, catalog) = open();
114+
let id = catalog.allocate_tenant_id().unwrap();
115+
catalog
116+
.put_tenant(&StoredTenant {
117+
tenant_id: id,
118+
name: "t".into(),
119+
created_at: 0,
120+
is_active: true,
121+
})
122+
.unwrap();
123+
catalog.delete_tenant(id).unwrap();
124+
// The dropped id must not be handed out again.
125+
assert!(catalog.allocate_tenant_id().unwrap() > id);
126+
}
127+
128+
#[test]
129+
fn self_heals_against_preexisting_tenants() {
130+
let (_dir, catalog) = open();
131+
// A tenant row written without ever bumping the counter (the
132+
// pre-fix world): insert directly so the hwm stays at 0, then
133+
// assert allocation still skips past the existing id.
134+
let bytes = zerompk::to_msgpack_vec(&StoredTenant {
135+
tenant_id: 500,
136+
name: "legacy".into(),
137+
created_at: 0,
138+
is_active: true,
139+
})
140+
.unwrap();
141+
let txn = catalog.db.begin_write().unwrap();
142+
{
143+
let mut t = txn.open_table(TENANTS).unwrap();
144+
t.insert("500", bytes.as_slice()).unwrap();
145+
}
146+
txn.commit().unwrap();
147+
assert!(catalog.allocate_tenant_id().unwrap() > 500);
148+
}
149+
150+
#[test]
151+
fn put_tenant_with_explicit_id_advances_hwm() {
152+
let (_dir, catalog) = open();
153+
// An explicitly-chosen high id must push the hwm forward so a
154+
// later auto-allocation never collides with it.
155+
catalog
156+
.put_tenant(&StoredTenant {
157+
tenant_id: 10,
158+
name: "explicit".into(),
159+
created_at: 0,
160+
is_active: true,
161+
})
162+
.unwrap();
163+
assert_eq!(catalog.allocate_tenant_id().unwrap(), 11);
164+
}
165+
}

nodedb/src/control/security/tenant.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ pub struct TenantIsolation {
102102
usage: HashMap<TenantId, TenantUsage>,
103103
/// Default quota applied to tenants without explicit config.
104104
default_quota: TenantQuota,
105+
/// Monotonic high-water-mark of every tenant id observed, used to
106+
/// allocate fresh ids without a catalog (the embedded / unit path).
107+
/// The catalog's durable `tenant_id_hwm` is authoritative when one
108+
/// is wired up; this mirror only serves the no-catalog fallback.
109+
id_hwm: u64,
105110
}
106111

107112
impl TenantIsolation {
@@ -110,14 +115,29 @@ impl TenantIsolation {
110115
quotas: HashMap::new(),
111116
usage: HashMap::new(),
112117
default_quota,
118+
id_hwm: 0,
113119
}
114120
}
115121

116122
/// Set quota for a specific tenant.
117123
pub fn set_quota(&mut self, tenant_id: TenantId, quota: TenantQuota) {
124+
self.id_hwm = self.id_hwm.max(tenant_id.as_u64());
118125
self.quotas.insert(tenant_id, quota);
119126
}
120127

128+
/// Allocate the next tenant id from the in-memory high-water-mark,
129+
/// skipping the reserved system (`0`) and bootstrap (`1`) slots.
130+
/// Used only when no catalog is wired up; with a catalog,
131+
/// `SystemCatalog::allocate_tenant_id` is the durable authority.
132+
pub fn allocate_tenant_id(&mut self) -> u64 {
133+
let next = self
134+
.id_hwm
135+
.max(crate::control::security::catalog::tenant_id_hwm::FIRST_USER_TENANT_ID - 1)
136+
+ 1;
137+
self.id_hwm = next;
138+
next
139+
}
140+
121141
/// Whether a tenant already has an explicit quota record.
122142
pub fn has_quota(&self, tenant_id: TenantId) -> bool {
123143
self.quotas.contains_key(&tenant_id)

nodedb/src/control/server/pgwire/ddl/tenant/create.rs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ fn parse_tenant_options<'a>(rest: &[&'a str]) -> PgWireResult<TenantOptions<'a>>
5454
})
5555
}
5656

57+
/// The default username auto-provisioned as a tenant's `tenant_admin`
58+
/// when `CREATE TENANT` is run without an explicit `WITH ADMIN <user>`
59+
/// clause. Defined once here so the `DROP TENANT` cleanup path can
60+
/// identify and remove this lifecycle-owned account without the
61+
/// convention drifting between the two sites.
62+
pub(super) fn default_admin_username(tenant_name: &str) -> String {
63+
format!("{tenant_name}_admin")
64+
}
65+
5766
/// `CREATE TENANT [IF NOT EXISTS] <name> [ID <id>] [WITH ADMIN <user>]`
5867
///
5968
/// Creates a tenant with default quotas. Only superuser can create tenants.
@@ -95,21 +104,27 @@ pub fn create_tenant(
95104
return Ok(vec![Response::Execution(Tag::new("CREATE TENANT"))]);
96105
}
97106

98-
// Pick the tenant id under a short lock scope; do NOT mutate the
99-
// store yet — the post_apply side effect on every node seeds the
100-
// default quota when `PutTenant` commits.
101-
let tenant_id = {
102-
let tenants = match state.tenants.lock() {
103-
Ok(t) => t,
104-
Err(p) => p.into_inner(),
105-
};
106-
match opts.explicit_id {
107-
Some(id) => TenantId::new(id),
107+
// Pick the tenant id. An explicit `ID <n>` is honored verbatim;
108+
// otherwise allocate a fresh id from the durable, monotonic
109+
// high-water-mark so two `CREATE TENANT`s never collide and a
110+
// dropped id is never reused. The catalog counter is authoritative
111+
// when wired up; the in-memory mirror covers the no-catalog path.
112+
let tenant_id = match opts.explicit_id {
113+
Some(id) => TenantId::new(id),
114+
None => match state.credentials.catalog().as_ref() {
115+
Some(catalog) => TenantId::new(
116+
catalog
117+
.allocate_tenant_id()
118+
.map_err(|e| sqlstate_error("XX000", &format!("tenant id alloc: {e}")))?,
119+
),
108120
None => {
109-
let count = tenants.tenant_count() as u64;
110-
TenantId::new(count + 1)
121+
let mut tenants = match state.tenants.lock() {
122+
Ok(t) => t,
123+
Err(p) => p.into_inner(),
124+
};
125+
TenantId::new(tenants.allocate_tenant_id())
111126
}
112-
}
127+
},
113128
};
114129

115130
let now = std::time::SystemTime::now()
@@ -148,7 +163,7 @@ pub fn create_tenant(
148163
let admin_name = opts
149164
.admin_override
150165
.map(str::to_string)
151-
.unwrap_or_else(|| format!("{name}_admin"));
166+
.unwrap_or_else(|| default_admin_username(name));
152167
let admin_password = {
153168
use sha2::{Digest, Sha256};
154169
let mut hasher = Sha256::new();

0 commit comments

Comments
 (0)