diff --git a/app/src/main/java/org/vss/impl/postgres/PostgresBackendImpl.java b/app/src/main/java/org/vss/impl/postgres/PostgresBackendImpl.java index 5fec767..14766c9 100644 --- a/app/src/main/java/org/vss/impl/postgres/PostgresBackendImpl.java +++ b/app/src/main/java/org/vss/impl/postgres/PostgresBackendImpl.java @@ -8,6 +8,7 @@ import java.util.Objects; import javax.inject.Singleton; import org.jooq.DSLContext; +import org.jooq.DeleteConditionStep; import org.jooq.Insert; import org.jooq.Query; import org.jooq.Update; @@ -108,24 +109,55 @@ public PutObjectResponse put(PutObjectRequest request) { } private Query buildDeleteObjectQuery(DSLContext dsl, VssDbRecord vssRecord) { + if (vssRecord.getVersion() == -1) { + return buildNonConditionalDeleteQuery(dsl, vssRecord); + } else { + return buildConditionalDeleteQuery(dsl, vssRecord); + } + } + + private static DeleteConditionStep buildNonConditionalDeleteQuery(DSLContext dsl, + VssDbRecord vssRecord) { + return dsl.deleteFrom(VSS_DB).where(VSS_DB.STORE_ID.eq(vssRecord.getStoreId()) + .and(VSS_DB.KEY.eq(vssRecord.getKey()))); + } + + private static DeleteConditionStep buildConditionalDeleteQuery(DSLContext dsl, + VssDbRecord vssRecord) { return dsl.deleteFrom(VSS_DB).where(VSS_DB.STORE_ID.eq(vssRecord.getStoreId()) .and(VSS_DB.KEY.eq(vssRecord.getKey())) .and(VSS_DB.VERSION.eq(vssRecord.getVersion()))); } private Query buildPutObjectQuery(DSLContext dsl, VssDbRecord vssRecord) { - return vssRecord.getVersion() == 0 ? buildInsertRecordQuery(dsl, vssRecord) - : buildUpdateRecordQuery(dsl, vssRecord); + if (vssRecord.getVersion() == -1) { + return buildNonConditionalUpsertRecordQuery(dsl, vssRecord); + } else if (vssRecord.getVersion() == 0) { + return buildConditionalInsertRecordQuery(dsl, vssRecord); + } else { + return buildConditionalUpdateRecordQuery(dsl, vssRecord); + } + } + + private Query buildNonConditionalUpsertRecordQuery(DSLContext dsl, VssDbRecord vssRecord) { + return dsl.insertInto(VSS_DB) + .values(vssRecord.getStoreId(), vssRecord.getKey(), + vssRecord.getValue(), 1) + .onConflict(VSS_DB.STORE_ID, VSS_DB.KEY) + .doUpdate() + .set(VSS_DB.VALUE, vssRecord.getValue()) + .set(VSS_DB.VERSION, 1L); } - private Insert buildInsertRecordQuery(DSLContext dsl, VssDbRecord vssRecord) { + private Insert buildConditionalInsertRecordQuery(DSLContext dsl, + VssDbRecord vssRecord) { return dsl.insertInto(VSS_DB) .values(vssRecord.getStoreId(), vssRecord.getKey(), vssRecord.getValue(), 1) .onDuplicateKeyIgnore(); } - private Update buildUpdateRecordQuery(DSLContext dsl, VssDbRecord vssRecord) { + private Update buildConditionalUpdateRecordQuery(DSLContext dsl, VssDbRecord vssRecord) { return dsl.update(VSS_DB) .set(Map.of(VSS_DB.VALUE, vssRecord.getValue(), VSS_DB.VERSION, vssRecord.getVersion() + 1)) diff --git a/app/src/main/proto/vss.proto b/app/src/main/proto/vss.proto index b110ac2..ab110d5 100644 --- a/app/src/main/proto/vss.proto +++ b/app/src/main/proto/vss.proto @@ -73,18 +73,26 @@ message PutObjectRequest { // a database-transaction in an all-or-nothing fashion. // All Items in a single `PutObjectRequest` must have distinct keys. // - // Clients are expected to store a `version` against every `key`. - // The write will succeed if the current DB version against the `key` is the same as in the request. - // When initiating a `PutObjectRequest`, the request should contain their client-side version for - // that key-value. - // - // For the first write of any key, the `version` should be '0'. If the write succeeds, the client - // must increment their corresponding key versions (client-side) by 1. - // The server increments key versions (server-side) for every successful write, hence this - // client-side increment is required to ensure matching versions. These updated key versions should - // be used in subsequent `PutObjectRequest`s for the keys. + // Key-level versioning (Conditional Write): + // Clients are expected to store a `version` against every `key`. + // The write will succeed if the current DB version against the `key` is the same as in the request. + // When initiating a `PutObjectRequest`, the request should contain their client-side `version` + // for that key-value. // - // Requests with a conflicting version will fail with `CONFLICT_EXCEPTION` as ErrorCode. + // For the first write of any `key`, the `version` should be '0'. If the write succeeds, the client + // must increment their corresponding key versions (client-side) by 1. + // The server increments key versions (server-side) for every successful write, hence this + // client-side increment is required to ensure matching versions. These updated key versions should + // be used in subsequent `PutObjectRequest`s for the keys. + // + // Requests with a conflicting/mismatched version will fail with `CONFLICT_EXCEPTION` as ErrorCode + // for conditional writes. + // + // Skipping key-level versioning (Non-conditional Write): + // If you wish to skip key-level version checks, set the `version` against the `key` to '-1'. + // This will perform a non-conditional write query, after which the `version` against the `key` + // is reset to '1'. Hence, the next `PutObjectRequest` for the `key` can be either + // a non-conditional write or a conditional write with `version` set to `1`. // // Considerations for transactions: // Transaction writes of multiple items have a performance overhead, hence it is recommended to use @@ -101,13 +109,20 @@ message PutObjectRequest { // Items to be deleted as a result of this `PutObjectRequest`. // // Each item in the `delete_items` field consists of a `key` and its corresponding `version`. - // The `version` is used to perform a version check before deleting the item. - // The delete will only succeed if the current database version against the `key` is the same as the `version` - // specified in the request. + // + // Key-Level Versioning (Conditional Delete): + // The `version` is used to perform a version check before deleting the item. + // The delete will only succeed if the current database version against the `key` is the same as + // the `version` specified in the request. + // + // Skipping key-level versioning (Non-conditional Delete): + // If you wish to skip key-level version checks, set the `version` against the `key` to '-1'. + // This will perform a non-conditional delete query. // // Fails with `CONFLICT_EXCEPTION` as the ErrorCode if: // * The requested item does not exist. - // * The requested item does exist but there is a version-number mismatch with the one in the database. + // * The requested item does exist but there is a version-number mismatch (in conditional delete) + // with the one in the database. // // Multiple items in the `delete_items` field, along with the `transaction_items`, are written in a // database transaction in an all-or-nothing fashion. @@ -133,8 +148,15 @@ message DeleteObjectRequest { // Item to be deleted as a result of this `DeleteObjectRequest`. // // An item consists of a `key` and its corresponding `version`. - // The item is only deleted if the current database version against the `key` is the same as the `version` - // specified in the request. + // + // Key-level Versioning (Conditional Delete): + // The item is only deleted if the current database version against the `key` is the same as + // the `version` specified in the request. + // + // Skipping key-level versioning (Non-conditional Delete): + // If you wish to skip key-level version checks, set the `version` against the `key` to '-1'. + // This will perform a non-conditional delete query. + // // This operation is idempotent, that is, multiple delete calls for the same item will not fail. // // If the requested item does not exist, this operation will not fail. diff --git a/app/src/test/java/org/vss/AbstractKVStoreIntegrationTest.java b/app/src/test/java/org/vss/AbstractKVStoreIntegrationTest.java index 45868c7..3c7ac32 100644 --- a/app/src/test/java/org/vss/AbstractKVStoreIntegrationTest.java +++ b/app/src/test/java/org/vss/AbstractKVStoreIntegrationTest.java @@ -29,15 +29,26 @@ public abstract class AbstractKVStoreIntegrationTest { @Test void putShouldSucceedWhenSingleObjectPutOperation() { + // Conditional Put assertDoesNotThrow(() -> putObjects(0L, List.of(kv("k1", "k1v1", 0)))); assertDoesNotThrow(() -> putObjects(1L, List.of(kv("k1", "k1v2", 1)))); + // NonConditional Put + assertDoesNotThrow(() -> putObjects(2L, List.of(kv("k2", "k2v1", -1)))); + assertDoesNotThrow(() -> putObjects(3L, List.of(kv("k2", "k2v2", -1)))); + assertDoesNotThrow(() -> putObjects(4L, List.of(kv("k2", "k2v3", -1)))); + KeyValue response = getObject("k1"); assertThat(response.getKey(), is("k1")); assertThat(response.getVersion(), is(2L)); assertThat(response.getValue().toStringUtf8(), is("k1v2")); - assertThat(getObject(KVStore.GLOBAL_VERSION_KEY).getVersion(), is(2L)); + response = getObject("k2"); + assertThat(response.getKey(), is("k2")); + assertThat(response.getVersion(), is(1L)); + assertThat(response.getValue().toStringUtf8(), is("k1v3")); + + assertThat(getObject(KVStore.GLOBAL_VERSION_KEY).getVersion(), is(5L)); } @Test @@ -168,11 +179,21 @@ void putAndDeleteShouldSucceedAsAtomicTransaction() { @Test void deleteShouldSucceedWhenItemExists() { assertDoesNotThrow(() -> putObjects(null, List.of(kv("k1", "k1v1", 0)))); + // Conditional Delete assertDoesNotThrow(() -> deleteObject(kv("k1", "", 1))); KeyValue response = getObject("k1"); assertThat(response.getKey(), is("k1")); assertTrue(response.getValue().isEmpty()); + + assertDoesNotThrow(() -> putObjects(null, List.of(kv("k1", "k1v1", 0)))); + assertDoesNotThrow(() -> putObjects(null, List.of(kv("k1", "k1v2", 1)))); + // NonConditional Delete + assertDoesNotThrow(() -> deleteObject(kv("k1", "", -1))); + + response = getObject("k1"); + assertThat(response.getKey(), is("k1")); + assertTrue(response.getValue().isEmpty()); } @Test