Skip to content

Remove non-transactional sequence state, and disallow non-default sequence params - #5880

Open
gefjon wants to merge 7 commits into
masterfrom
phoebe/sequence-remove-config
Open

Remove non-transactional sequence state, and disallow non-default sequence params#5880
gefjon wants to merge 7 commits into
masterfrom
phoebe/sequence-remove-config

Conversation

@gefjon

@gefjon gefjon commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Description of Changes

Our sequences code had a lot of complexity that wasn't paying for itself, and was causing bugs. This commit simplifies it in two ways:

  1. Sequences are no longer configurable. All sequences now have a step of 1 and a range of 1..=i128::MAX, all user sequences start at 1, and all system table sequences start just after the reserved range. Even prior to this change, sequences were only used to implement auto_inc columns, and auto_inc columns all configured their sequences in exactly this way. It was technically possible for a module which entirely bypassed our APIs, and emitted a RawModuleDefV10 on its own, to configure a sequence otherwise, but we have no reason to believe anyone did this and do not consider it supported.

  2. Sequences no longer pre-allocate chunks of values, and instead just update their st_sequence rows directly. Prior to this change, we kept an in-memory non-transactional side table of sequence states. This is a common optimization in concurrent databases with MVCC datastores, as it prevents contention on the system table rows which implement the sequences persistently. However, SpacetimeDB is not a concurrent database and does not have an MVCC datastore, so contention is not an issue.

This change was brought on by a bug wherein a transaction could allocate a new block of sequence values and then roll back. This left the in-memory side table advanced but the persistent st_sequence row not updated, which, when using a sequence to implement the auto_inc portion of an auto_inc unique column, led to unique constraint violations when the database restarted and then re-allocated the same block of values. Abandoning the in-memory side table entirely is sufficient (though not necessary) to prevent this bug.

In order to prevent users from relying on the determinism and sequentiality of the new implementation, we use a PRNG to artificially skip values occasionally when reading from a sequence.

API and ABI breaking changes

The RawModuleDef ABI family (V8, V9 and V10) have their layout preserved, but the validation pass is now more restrictive than it was previously, and rejects SequenceDefs which would previously have been rejected. Modules defined using the SpacetimeDB module libraries (Rust, TypeScript, C# and C++) would never exercise this difference, and will not be rejected.

Rollback safety impact

n/a

Expected complexity level and risk

3: it may be somewhat difficult to convince ourselves that:

  1. In normal operation, SpacetimeDB never exercised the configuration space of sequences which this commit removes.
  2. The new system for pulling values from sequences yields values in a way that is compatible with the previous version. This is mostly hard, in my mind, because the previous version was complex, and its API guarantees were under-documented.

Testing

  • Manually ran my reproducer for the original bug, which relied on a failed automigration to cause a unique constraint violation on st_index's index_id column.
  • Added a new smoketest, autoinc_sequence_allocation_remains_consistent_after_rollback, which fails on master but passes on this branch.
  • Updated a few tests in datastore.rs which previously contained assertions about how a rolled-back transaction could consume sequence values, so that now it instead contains assertions that rolled-back transactions do not do so.
  • Added a test in datastore.rs, sequence_occasionally_skips_values_to_simulate_reallocation, which demonstrates that we occasionally skip values when advancing a sequence in order to simulate block allocation.

…uence params

Our sequences code had a lot of complexity that wasn't paying for itself,
and was causing bugs.
This commit simplifies it in two ways:

1. Sequences are no longer configurable.
   All sequences now have a step of 1 and a range of `1..=i128::MAX`,
   all user sequences start at 1,
   and all system table sequences start just after the reserved range.
   Even prior to this change, sequences were only used to implement `auto_inc` columns,
   and `auto_inc` columns all configured their sequences in exactly this way.
   It was technically possible for a module which entirely bypassed our APIs,
   and emitted a `RawModuleDefV10` on its own,
   to configure a sequence otherwise,
   but we have no reason to believe anyone did this and do not consider it supported.

2. Sequences no longer pre-allocate chunks of values,
   and instead just update their `st_sequence` rows directly.
   Prior to this change, we kept an in-memory non-transactional side table of sequence states.
   This is a common optimization in concurrent databases with MVCC datastores,
   as it prevents contention on the system table rows which implement the sequences persistently.
   However, SpacetimeDB is not a concurrent database and does not have an MVCC datastore,
   so contention is not an issue.

This change was brought on by a bug
wherein a transaction could allocate a new block of sequence values and then roll back.
This left the in-memory side table advanced but the persistent `st_sequence` row not updated,
which, when using a sequence to implement the `auto_inc` portion of an `auto_inc` unique column,
led to unique constraint violations
when the database restarted and then re-allocated the same block of values.
Abandoning the in-memory side table entirely is sufficient (though not necessary)
to prevent this bug.
I even ran it against the pre-change version and saw it fail!
We had a few unit tests that contained assertions about sequences' previous behavior,
where rolled back transactions could consume auto-incremented values.
Rolled back transactions cannot consume auto-incremented values anymore,
so these tests change slightly.
@gefjon
gefjon marked this pull request as ready for review September 7, 2026 20:42
/// so that tests which rely on auto-inc sequences will be deterministic.
/// See [`CommittedState::sequence_advance_simulate_reallocation_rng`].
fn should_simulate_sequence_reallocation(rng: &mut Xoshiro128PlusPlus) -> bool {
// Skip an average of once every 4096 values, the same as the chunk size.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we will skip an average of 2048 steps out of every 4096 allocations. That feels like a large portion of the range to skip, and a low enough frequency that it would be easy for testing to not hit it. What do you think about something like, add one extra step every 4 allocations (on average)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did introduce a test that hit the skip, though it did require quite a few inserts to hit the case. My concern with a smaller skip is that it won't be noticeable to users. I wanted to pick a skipping behavior where users will be easily able to tell that it's happening just by looking at, say, a list of values, and so I wanted both a large gap and to skip to an easily identifiable round number.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we could consider more closely emulating the previous behavior: don't do random skips during operation, but do skip ahead when restarting.

.unwrap();
let old_seq_row_ptr = old_seq_row_ref.pointer();
let seq_row = {
let (seq_row, value) = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could just use seq_row as the value to simplify a little bit. The reallocation case would just bump the allocation one step sooner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean, later on when we read value, we could instead read seq_row.allocated - 1? We could do that, and it would use fewer lines of code, but I don't particularly see it as simpler.

/// The supplied `rng` should be the one in the committed state for this purpose,
/// so that tests which rely on auto-inc sequences will be deterministic.
/// See [`CommittedState::sequence_advance_simulate_reallocation_rng`].
fn should_simulate_sequence_reallocation(rng: &mut Xoshiro128PlusPlus) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this using a different rng than the one that is used by modules?

@gefjon gefjon Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this uses a new PRNG held by the CommittedState just for this purpose. Unlike the one used by modules:

  1. It lives in the host space, not within the module VM.
  2. It persists across transactions.
  3. It is not a cryptographic PRNG, it is instead a lightweight and fast algorithm.
  4. It is seeded directly from the OS's RNG source (except in #[cfg(test)], where it instead has a stable seed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants