Skip to content

Conversation

Mytherin
Copy link
Collaborator

Previously, attached databases were tracked in a transactional manner using a CatalogSet tied to the system catalog. This would make all operations on them transactional in relation to the system catalog. When attaching, attached databases would only be visible to transactions after the attach has been committed. Similarly, detaching would not instantly detach the database, but would postpone the actual detach until all transactions on the system catalog were terminated.

This seems elegant but can cause issues / hard-to-understand behavior:

  • Databases would only be detached on cleanup
  • Since every transaction uses the system transaction, effectively any open transaction could defer the clean-up of a database
  • We can only run the final checkpoint (checkpoint on shutdown) after the database has fully detached/shut down, which would be deferred from the actual detach

In particular, this could cause issues when detaching a database file, followed by re-attaching the same database file. If the detach had not fully completed yet - we would allow the attach, but could then have a checkpoint running in the background while other operations were happening on the attached database file. This could lead to data corruption and similar errors as we would have multiple writers writing to the same database file.

This data corruption can be fixed differently by detecting this and throwing an error preventing the attach - but this would then create more confusion from a user perspective. The database would be detached, but not cleaned up yet in the background because other transactions are still active, even if those transactions are not using that specific database. As a result, it is hard to tell when a detach would actually happen and when we could re-attach the same database again.

Direct Detach

In order to solve these issues - this PR moves away from using a CatalogSet for attached databases. Instead, we use a map of shared pointers. When a database is referenced by a transaction, that transaction grabs a shared pointer to that database and stores it, preventing the database from being detached as long as that transaction is active.

When a detach happens, the database is removed from the set of databases in the database manager. At this point, it will be directly cleaned up if no other transaction is actively using that database. If another transaction is still using the database, the database will be cleaned up after that transaction is finished. As a result, detaching while other transactions are using the database is still safe.

This does mean that Detach is no longer transactional - i.e. a rollback can no longer roll back a detach. If a transaction tries to use a database after it has been detached by another transaction, that database can disappear even if it has existed in the past.

Attaching works slightly differently. It is also no longer transactional - so if a transaction attaches a database, other transactions can immediately start using that database. However, attaching can still be rolled back. Effectively if we attach and then roll back, we will detach the database during rollback.

@Tishj
Copy link
Contributor

Tishj commented Aug 22, 2025

we will detach the database during rollback.

Only for the transaction that started the attach right?
If another connection would start using the attached db before the original transaction has done their rollback, the database would remain "attached", because the other connection is using it still?

The question is purely informational, just trying to see if I understand this behavior correctly

@taniabogatsch
Copy link
Contributor

the database would remain "attached", because the other connection is using it still?

only until that other connection / query releases the shared pointer, I think. So , in a very theoretical case, we could keep it alive by always having someone use it?

@Mytherin
Copy link
Collaborator Author

Detach is not transactional anymore after this change, it will be detached globally even if another transaction is using it. Subsequent attempts to refer to the catalog will fail, even if they succeeded previously within the same transaction, e.g.:

statement ok
SET immediate_transaction_mode=true;

statement ok con1
BEGIN

statement ok con2
BEGIN

statement ok con1
ATTACH ':memory:' AS newly_attached;

# succeeds, we can immediately refer to newly_attached
statement ok con2
CREATE TABLE newly_attached.db(i INTEGER);

statement ok con1
ROLLBACK

# fails, newly_attached has been detached - even though we used it previously in the same transaction by con2
statement error con2
CREATE TABLE newly_attached.db(i INTEGER);
----
Catalog Error: Schema with name newly_attached does not exist!

However, the actual detach / shutdown of the database will not happen until "con2" has finished its transaction - so in the above example "newly_attached_db" is still being kept alive / attached by the existence of "con2" as "con2" has outstanding changes made to that database. It can just not be referenced.

@Mytherin
Copy link
Collaborator Author

Old behavior:

  • Attach / Detach follow transactional semantics, we can only refer to databases that have been "committed". If we can refer to a database during a transaction, it will always be available later on in that same transaction.
  • Actual destruction of a database post-detach will happen when all transactions that were open prior to detaching have finished, even if they never actually used / referred to that database

New behavior:

  • Attach / Detach happen immediately, we can immediately refer to other databases that have been attached and detach immediately removes our access to the database that is detached.
  • Actual destruction of a database post-detach happens after all transactions that have explicitly used that database have finished.

@Mytherin
Copy link
Collaborator Author

For reference, in SQLite attaching / detaching is completely non-transactional:

sqlite> BEGIN;
sqlite> ATTACH ':memory:' as newly_attached;
sqlite> ROLLBACK;
sqlite> CREATE TABLE newly_attached.tbl(i INTEGER);
sqlite> INSERT INTO newly_attached.tbl VALUES (42);
sqlite> SELECT * FROM newly_attached.tbl;
┌────┐
│ i  │
├────┤
│ 42 │
└────┘

Copy link
Contributor

@taniabogatsch taniabogatsch left a comment

Choose a reason for hiding this comment

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

Hi - left some comments, mostly questions :)

createLookupTbl(*conn, dbId);
break;
default:
throw runtime_error("invalid scenario");
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: maybe this can be an internal error or so?

Comment on lines +133 to +134
// execQuery(initConn, "SET default_block_size = '16384'");
// execQuery(initConn, "SET storage_compatibility_version = 'v1.3.2'");
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to add these back in? Or fully remove them?

}

void workUnit(std::unique_ptr<Connection> conn) {
for (int i = 0; i < iterationCount; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this test will probably be useful for simulating more complex scenarios in the future: do we want to use the success atomic to stop iteration here? So that all threads early-out, if one fails?

Unique file handle conflict

statement maybe
CREATE TABLE attach_mix_${k}_${i} AS SELECT * FROM range(${k} * ${i}) t(i)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a dot missing in this test? attach_mix_${k}._${i}?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes great point, this actually found some issues

Comment on lines +46 to +51
case UndoFlags::ATTACHED_DATABASE: {
auto db = Load<AttachedDatabase *>(data);
auto &db_manager = DatabaseManager::Get(db->GetDatabase());
db_manager.DetachInternal(db->name);
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the benefit of being able to rollback an ATTACH? Is it mostly error-prevention of failing a query/transaction but having a lingering attached database?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes, making sure we clean-up after errors. This is mostly relevant for single-statement ATTACH queries, e.g. when encountering an error in ATTACH '...' we will now always clean up the attached database (if the error happened post adding it to the database manager).

@duckdb-draftbot duckdb-draftbot marked this pull request as draft August 22, 2025 09:25
@Mytherin Mytherin marked this pull request as ready for review August 22, 2025 09:28
…he database manager after it is fully loaded
@duckdb-draftbot duckdb-draftbot marked this pull request as draft August 22, 2025 11:39
@Mytherin Mytherin marked this pull request as ready for review August 22, 2025 13:24
@Mytherin Mytherin merged commit df0a3de into duckdb:v1.3-ossivalis Aug 22, 2025
49 checks passed
github-actions bot pushed a commit to duckdb/duckdb-r that referenced this pull request Aug 22, 2025
Move attached databases from a CatalogSet to a dedicated map of shared pointers (duckdb/duckdb#18693)
Unplug python (in ossivalis) (duckdb/duckdb#18699)
Using a different workflow to release the python package (duckdb/duckdb#18685)
Add WAL test config run (duckdb/duckdb#18683)
[CI] Temporarily skip triggering `R Package Windows (Extensions)` job (duckdb/duckdb#18628)
Load pandas in import cache before binding (duckdb/duckdb#18658)
Remove `PRAGMA enable_verification` in more tests (duckdb/duckdb#18670)
Remove more `PRAGMA enable_verification` (duckdb/duckdb#18664)
Remove `PRAGMA enable_verification` (duckdb/duckdb#18645)
github-actions bot added a commit to duckdb/duckdb-r that referenced this pull request Aug 22, 2025
Move attached databases from a CatalogSet to a dedicated map of shared pointers (duckdb/duckdb#18693)
Unplug python (in ossivalis) (duckdb/duckdb#18699)
Using a different workflow to release the python package (duckdb/duckdb#18685)
Add WAL test config run (duckdb/duckdb#18683)
[CI] Temporarily skip triggering `R Package Windows (Extensions)` job (duckdb/duckdb#18628)
Load pandas in import cache before binding (duckdb/duckdb#18658)
Remove `PRAGMA enable_verification` in more tests (duckdb/duckdb#18670)
Remove more `PRAGMA enable_verification` (duckdb/duckdb#18664)
Remove `PRAGMA enable_verification` (duckdb/duckdb#18645)

Co-authored-by: krlmlr <krlmlr@users.noreply.github.com>
@Mytherin Mytherin deleted the attachnottransactional branch September 3, 2025 07:05
Mytherin added a commit that referenced this pull request Sep 4, 2025
…nsaction (#18850)

Follow-up fixes to #18693

* Make `ATTACH OR REPLACE` atomic
* Keep a list of used databases in the MetaTransaction, so that if we
use a database with a given name in a transaction, we can keep on using
that database with that name
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Documentation Use for issues or PRs that require changes in the documentation
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants