Skip to content

Conversation

@jmpesp
Copy link
Contributor

@jmpesp jmpesp commented Aug 24, 2023

If project_ensure_snapshot is passed a snapshot where the project and name matches a record that exists already, but the ID is different, then

.on_conflict((dsl::project_id, dsl::name))
.filter_target(dsl::time_deleted.is_null())
.do_update()
.set(dsl::time_modified.eq(dsl::time_modified)),

will incorrectly modify the existing record, leading callers of that function to erroneously believe they were the one to create that snapshot record. Change project_ensure_snapshot to check if an existing snapshot record has a matching project and name, and return ObjectAlreadyExists if so.

It's not appropriate to delete a snapshot in any state: if it's in state Creating, then a snapshot_create saga is working on it and concurrently running snapshot_delete will cause a conflict. Add an argument to project_delete_snapshot that describes the states that a snapshot can be deleted in, depending on on the call site.

If `project_ensure_snapshot` is passed a snapshot where the project and
name matches a record that exists already, but the ID is different, then

    .on_conflict((dsl::project_id, dsl::name))
    .filter_target(dsl::time_deleted.is_null())
    .do_update()
    .set(dsl::time_modified.eq(dsl::time_modified)),

will incorrectly modify the existing record, leading callers of that
function to erroneously believe they were the one to create that
snapshot record. Change `project_ensure_snapshot` to check if an
existing snapshot record has a matching project and name, and return
ObjectAlreadyExists if so.

It's not appropriate to delete a snapshot in any state: if it's in state
Creating, then a `snapshot_create` saga is working on it and
concurrently running `snapshot_delete` will cause a conflict. Add an
argument to `project_delete_snapshot` that describes the states that a
snapshot can be deleted in, depending on on the call site.
}
})?;

let snapshot: Snapshot = self
Copy link
Collaborator

@smklein smklein Aug 24, 2023

Choose a reason for hiding this comment

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

(Looking at the old Project::insert_resource code) Could we do this without the transaction? diesel::insert_into should return the inserted row, which goes through insert_and_get_result_async.

It seems possible to call either:

Something like:

       let snapshots: Vec<Snapshot> = Project::insert_resource(
            project_id,
            diesel::insert_into(dsl::snapshot)
                .values(snapshot)
                .on_conflict((dsl::project_id, dsl::name))
                .filter_target(dsl::time_deleted.is_null())
                .do_nothing(),
        )
        .insert_and_get_results_async(self.pool_authorized(opctx).await?)
        .await
        .map_err(|e| match e {
            AsyncInsertError::CollectionNotFound => Error::ObjectNotFound {
                type_name: ResourceType::Project,
                lookup_type: LookupType::ById(project_id),
            },
            AsyncInsertError::DatabaseError(e) => {
                public_error_from_diesel_pool(e, ErrorHandler::Server)
            }
        })?;
        let Some(snapshot) = snapshots.get(0) else {
            // Return an error -- we failed to insert the snapshot.
        };

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I originally tried insert_and_get_result_async with the additional filter (once I got it working), but I saw that it returned CollectionNotFound instead of DatabaseError. I'm trying the second suggestion now.

Comment on lines 60 to 61
.do_update()
.set(dsl::time_modified.eq(dsl::time_modified))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we still want to update the old record in this case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely not, but I'm at a loss as how to do that.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we use on_conflict().do_nothing()?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I do

        let mut snapshots: Vec<Snapshot> = Project::insert_resource(
            project_id,
            diesel::insert_into(dsl::snapshot)
                .values(snapshot)
                .on_conflict((dsl::project_id, dsl::name))
                .filter_target(dsl::time_deleted.is_null())
                .do_nothing()
                .filter(dsl::id.eq(snapshot_id)),
        )
        .insert_and_get_results_async(self.pool_authorized(opctx).await?)

then I see a syntax error:

thread 'integration_tests::snapshots::test_create_snapshot_record_idempotent' panicked at 'called `Result::unwrap()` on an `Err` value: InternalError { internal_message: "unexpected database error: at or near \"where\": syntax error" }', nexus/tests/integration_tests/snapshots.rs:996:10

If I remove the .filter(dsl::id.eq(snapshot_id)), on the end of that (so it's just on_conflict + do_nothing, I see test_create_snapshot_record_idempotent failing on the second call here:

    // Test project_ensure_snapshot is idempotent

    let snapshot_created_1 = datastore
        .project_ensure_snapshot(&opctx, &authz_project, snapshot.clone())
        .await
        .unwrap();
                
    let snapshot_created_2 = datastore
        .project_ensure_snapshot(&opctx, &authz_project, snapshot.clone())
        .await
        .unwrap();

saying the snapshot already exists:

thread 'integration_tests::snapshots::test_create_snapshot_record_idempotent' panicked at 'called `Result::unwrap()` on an `Err` value: ObjectAlreadyExists { type_name: Snapshot, object_name: "snapshot" }', nexus/tests/integration_tests/snapshots.rs:1001:10

This reverts commit 919c506: using a
single statement modifies the existing record's `time_modified` column.
@jmpesp
Copy link
Contributor Author

jmpesp commented Aug 29, 2023

We spoke in DMs, and I reverted to the transaction to avoid modifying the existing snapshot's time_modified column.

@jmpesp jmpesp merged commit 58e8c67 into oxidecomputer:main Aug 30, 2023
@jmpesp jmpesp deleted the snapshot_woes branch August 30, 2023 16:31
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