Skip to main content
Version: 3.19New

ScalarDB Cluster Java API Guide

The ScalarDB Cluster Java API is composed of the Administrative API and Transaction API, which are part of ScalarDB Core, as well as additional APIs specific to ScalarDB Cluster, such as the Microservice Transaction API. This guide explains what kinds of APIs exist, how to use them, and related topics like how to handle exceptions.

Administrative API​

This section explains how to execute administrative operations programmatically by using the Administrative API in ScalarDB.

warning

When an Administrative API call writes to the underlying databases, it triggers several write operations. However, these operations are not executed atomically, meaning that if the call fails midway, you may encounter inconsistent states. To resolve this inconsistency issue, you can repair the namespace or table. For details, see the following pages:

note

Another method for executing administrative operations is to use Schema Loader.

Get a DistributedTransactionAdmin instance​

You first need to get a DistributedTransactionAdmin instance to execute administrative operations.

To get a DistributedTransactionAdmin instance, you can use TransactionFactory as follows:

TransactionFactory transactionFactory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
DistributedTransactionAdmin admin = transactionFactory.getTransactionAdmin();

For details about configurations, see ScalarDB Configurations.

After you have executed all administrative operations, you should close the DistributedTransactionAdmin instance as follows:

admin.close();

Create a namespace​

Before creating tables, namespaces must be created since a table belongs to one namespace.

You can create a namespace as follows:

// Create the namespace "ns". If the namespace already exists, an exception will be thrown.
admin.createNamespace("ns");

// Create the namespace only if it does not already exist.
boolean ifNotExists = true;
admin.createNamespace("ns", ifNotExists);

// Create the namespace with options.
Map<String, String> options = ...;
admin.createNamespace("ns", options);

Creation options​

In the namespace creation operations, you can specify options that are maps of option names and values (Map<String, String>). By using the options, you can set storage adapter–specific configurations.

Select your database to see the options available:

No options are available.

Create a table​

When creating a table, you should define the table metadata and then create the table.

To define the table metadata, you can use TableMetadata. The following shows how to define the columns, partition key, clustering key including clustering orders, and secondary indexes of a table:

// Define the table metadata.
TableMetadata tableMetadata =
TableMetadata.newBuilder()
.addColumn("c1", DataType.INT)
.addColumn("c2", DataType.TEXT)
.addColumn("c3", DataType.BIGINT)
.addColumn("c4", DataType.FLOAT)
.addColumn("c5", DataType.DOUBLE)
.addPartitionKey("c1")
.addClusteringKey("c2", Scan.Ordering.Order.DESC)
.addClusteringKey("c3", Scan.Ordering.Order.ASC)
.addSecondaryIndex("c4")
.build();

For details about the data model of ScalarDB, see Model Your Data.

Then, create a table as follows:

// Create the table "ns.tbl". If the table already exists, an exception will be thrown.
admin.createTable("ns", "tbl", tableMetadata);

// Create the table only if it does not already exist.
boolean ifNotExists = true;
admin.createTable("ns", "tbl", tableMetadata, ifNotExists);

// Create the table with options.
Map<String, String> options = ...;
admin.createTable("ns", "tbl", tableMetadata, options);

Creation options​

In the table creation operations, you can specify options that are maps of option names and values (Map<String, String>). By using the options, you can set storage adapter–specific configurations.

Select your database to see the options available:

NameDescriptionDefault
transaction-metadata-decouplingEnable transaction metadata decoupling when using Consensus Commit, which manages the transaction metadata in a separate table from application data.false

Create a secondary index​

You can create a secondary index as follows:

// Create a secondary index on column "c5" for table "ns.tbl". If a secondary index already exists, an exception will be thrown.
admin.createIndex("ns", "tbl", "c5");

// Create the secondary index only if it does not already exist.
boolean ifNotExists = true;
admin.createIndex("ns", "tbl", "c5", ifNotExists);

// Create the secondary index with options.
Map<String, String> options = ...;
admin.createIndex("ns", "tbl", "c5", options);
warning

When using Consensus Commit, createIndex() on a non-primary-key column also creates a companion before-image secondary index. For details, see Correctness of index-based reads.

Creation options​

In the secondary index creation operations, you can specify options that are maps of option names and values (Map<String, String>). By using the options, you can set storage adapter–specific configurations.

Select your database to see the options available:

No options are available for JDBC databases.

Add a new column to a table​

You can add a new, non-partition key column to a table as follows:

// Add a new column "c6" with the INT data type to the table "ns.tbl".
admin.addNewColumnToTable("ns", "tbl", "c6", DataType.INT);

// Add the new column only if it does not already exist.
boolean ifNotExists = true;
admin.addNewColumnToTable("ns", "tbl", "c6", DataType.INT, false, ifNotExists);
warning

You should carefully consider adding a new column to a table because the execution time may vary greatly depending on the underlying storage. Please plan accordingly and consider the following, especially if the database runs in production:

  • For Cosmos DB for NoSQL and DynamoDB: Adding a column is almost instantaneous as the table schema is not modified. Only the table metadata stored in a separate table is updated.
  • For Cassandra: Adding a column will only update the schema metadata and will not modify the existing schema records. The cluster topology is the main factor for the execution time. Changes to the schema metadata are shared to each cluster node via a gossip protocol. Because of this, the larger the cluster, the longer it will take for all nodes to be updated.
  • For relational databases (MySQL, Oracle, etc.): Adding a column can cause a table rebuild depending on the database engine. In such cases, it can take a long time to execute.

Drop a column from a table​

You can drop a column from a table as follows:

// Drop the column "c6" from the table "ns.tbl".
admin.dropColumnFromTable("ns", "tbl", "c6");

// Drop the column only if it exists.
boolean ifExists = true;
admin.dropColumnFromTable("ns", "tbl", "c6", ifExists);
note

You cannot drop a column from a table in the following cases:

  • The column is part of the partition key or clustering key.
  • The table is on a non-JDBC database except for Cassandra.
warning

You should carefully consider dropping a column from a table because the execution time may vary greatly depending on the underlying storage. Please plan accordingly and consider the following, especially if the database runs in production:

  • For Cassandra: Dropping a column will only update the schema metadata and the actual data will be removed during the next compaction. The cluster topology is the main factor for the execution time. Changes to the schema metadata are shared to each cluster node via a gossip protocol. Because of this, the larger the cluster, the longer it will take for all nodes to be updated.
  • For relational databases (MySQL, Oracle, etc.): Dropping a column issues ALTER TABLE ... DROP COLUMN to the underlying relational databases and could trigger a table rebuild depending on the database. In such cases, it can take a long time to execute.

Rename a column of a table​

You can rename a column of a table as follows:

// Rename the column "c6" to "c66" in the table "ns.tbl".
admin.renameColumnInTable("ns", "tbl", "c6", "c66");
note

You cannot rename a column of a table in the following cases:

  • The table is on a non-JDBC database except for Cassandra.
  • For Cassandra, the column is not part of the partition key or clustering key.
  • For Db2, the column is part of the partition key, clustering key, or secondary index key.

Rename a table​

You can rename a table as follows:

// Rename the table "ns.tbl" to "ns.new_tbl".
admin.renameTable("ns", "tbl", "new_tbl");
note

You cannot rename a table on non-JDBC databases.

Alter a column data type of a table​

You can alter a column data type of a table as follows:

// Alter the data type of the column "c6" to BIGINT in the table "ns.tbl".
admin.alterColumnDataType("ns", "tbl", "c6", DataType.BIGINT);
note

You cannot alter a column data type of a table in the following cases:

  • The column is part of the partition key, clustering key, or secondary index key.
  • The table is on a non-JDBC database or on SQLite.
  • The conversions other than from INT to BIGINT, FLOAT to DOUBLE, and from any data to TEXT are specified.
  • For Oracle, the conversions except for from INT to BIGINT are specified.
  • For Db2 and TiDB, the conversion from BLOB to TEXT is specified.
warning

You should carefully consider altering a column type because the execution time may vary greatly depending on the underlying storage. Please plan accordingly and consider the following, especially if the database runs in production:

  • For relational databases (MySQL, Oracle, etc.): Altering a column issues ALTER TABLE ... ALTER COLUMN or ALTER TABLE ... MODIFY to the underlying relational databases and could trigger a table rebuild depending on the database. In such cases, it can take a long time to execute.

Truncate a table​

You can truncate a table as follows:

// Truncate the table "ns.tbl".
admin.truncateTable("ns", "tbl");

Drop a secondary index​

You can drop a secondary index as follows:

// Drop the secondary index on column "c5" from table "ns.tbl". If the secondary index does not exist, an exception will be thrown.
admin.dropIndex("ns", "tbl", "c5");

// Drop the secondary index only if it exists.
boolean ifExists = true;
admin.dropIndex("ns", "tbl", "c5", ifExists);
warning

When using Consensus Commit, dropIndex() also drops the companion before-image secondary index. For details, see Correctness of index-based reads.

Drop a table​

You can drop a table as follows:

// Drop the table "ns.tbl". If the table does not exist, an exception will be thrown.
admin.dropTable("ns", "tbl");

// Drop the table only if it exists.
boolean ifExists = true;
admin.dropTable("ns", "tbl", ifExists);

Drop a namespace​

You can drop a namespace as follows:

// Drop the namespace "ns". If the namespace does not exist, an exception will be thrown.
admin.dropNamespace("ns");

// Drop the namespace only if it exists.
boolean ifExists = true;
admin.dropNamespace("ns", ifExists);

Get existing namespaces​

You can get the existing namespaces as follows:

Set<String> namespaces = admin.getNamespaceNames();

Get the tables of a namespace​

You can get the tables of a namespace as follows:

// Get the tables of the namespace "ns".
Set<String> tables = admin.getNamespaceTableNames("ns");

Get table metadata​

You can get table metadata as follows:

// Get the table metadata for "ns.tbl".
TableMetadata tableMetadata = admin.getTableMetadata("ns", "tbl");

Repair a namespace​

If a namespace is in an unknown state, such as the namespace exists in the underlying storage but not its ScalarDB metadata or vice versa, this method will re-create the namespace and its metadata if necessary.

You can repair the namespace as follows:

// Repair the namespace "ns" with options.
Map<String, String> options = ...;
admin.repairNamespace("ns", options);

Repair a table​

If a table is in an unknown state, such as the table exists in the underlying storage but not its ScalarDB metadata or vice versa, this method will re-create the table, its secondary indexes, and their metadata if necessary.

You can repair the table as follows:

// Repair the table "ns.tbl" with options.
TableMetadata tableMetadata =
TableMetadata.newBuilder()
...
.build();
Map<String, String> options = ...;
admin.repairTable("ns", "tbl", tableMetadata, options);
warning

When using Consensus Commit, after upgrading to ScalarDB 3.16.5, 3.17.3, or 3.18.0 from an earlier version, you must run repairTable() on each existing table to create the companion before-image secondary indexes that index-based reads require. For details, see Correctness of index-based reads.

Upgrade the environment to support the latest ScalarDB API​

You can upgrade the ScalarDB environment to support the latest version of the ScalarDB API. Typically, as indicated in the release notes, you will need to run this method after updating the ScalarDB version that your application environment uses.

// Upgrade the ScalarDB environment.
Map<String, String> options = ...;
admin.upgrade(options);

Specify operations for the Coordinator table​

The Coordinator table is used by the Transaction API to track the statuses of transactions.

When using a transaction manager, you must create the Coordinator table to execute transactions. In addition to creating the table, you can truncate and drop the Coordinator table.

Create the Coordinator table​

You can create the Coordinator table as follows:

// Create the Coordinator table.
admin.createCoordinatorTables();

// Create the Coordinator table only if one does not already exist.
boolean ifNotExist = true;
admin.createCoordinatorTables(ifNotExist);

// Create the Coordinator table with options.
Map<String, String> options = ...;
admin.createCoordinatorTables(options);

Truncate the Coordinator table​

You can truncate the Coordinator table as follows:

// Truncate the Coordinator table.
admin.truncateCoordinatorTables();

Drop the Coordinator table​

You can drop the Coordinator table as follows:

// Drop the Coordinator table.
admin.dropCoordinatorTables();

// Drop the Coordinator table if one exist.
boolean ifExist = true;
admin.dropCoordinatorTables(ifExist);

Import a table​

You can import an existing table to ScalarDB as follows:

// Import the table "ns.tbl". If the table is already managed by ScalarDB, the target table does not
// exist, or the table does not meet the requirements of the ScalarDB table, an exception will be thrown.
admin.importTable("ns", "tbl", options, overrideColumnsType);
warning

When using Consensus Commit, you should carefully plan to import a table to ScalarDB in production because it will add transaction metadata columns to your database tables and the ScalarDB metadata tables. In this case, there would also be several differences between your database and ScalarDB, as well as some limitations. For details, see Importing Existing Tables to ScalarDB by Using ScalarDB Schema Loader.

You can also enable transaction metadata decoupling by specifying the transaction-metadata-decoupling option to true to store transaction metadata in a separate table from application data. For details, see Decoupling transaction metadata.

Authentication and authorization API​

ScalarDB Cluster supports authentication and authorization. To manage users, roles, and privileges programmatically via Java, use ClusterClientTransactionAdmin, which implements AuthAdmin.

For authentication and authorization concepts, including users, roles, and privileges, see Authenticate and authorize users.

Manage users​

The following operations let you create, modify, and retrieve users.

When creating or altering a user, you can specify the following options by using the UserOption enum:

OptionDescription
SUPERUSERCreates or sets the user as a superuser.
NO_SUPERUSERCreates or sets the user as a non-superuser. This is the default if neither option is specified.

You can also specify one or more authentication methods for a user by using the AuthenticationMethod enum:

Authentication methodDescription
USERPASSUsername and password-based authentication.
OIDCOpenID Connect (OIDC) authentication.
Create a user​

You can create a user as follows:

// Create a regular (non-superuser) user with a password.
admin.createUser("username", "password");

// Create a superuser.
admin.createUser("username", "password", AuthAdmin.UserOption.SUPERUSER);

// Create a user without a password for OIDC authentication.
admin.createUser("username", null, ImmutableSet.of(AuthAdmin.AuthenticationMethod.OIDC));

// Create a user with specific authentication methods.
admin.createUser("username", "password", ImmutableSet.of(AuthAdmin.AuthenticationMethod.USERPASS, AuthAdmin.AuthenticationMethod.OIDC));
Alter a user​

You can alter (modify) an existing user as follows:

// Change the password of an existing user.
admin.alterUser("username", "newpassword");

// Remove the password of an existing user (pass an empty string to delete the password).
admin.alterUser("username", "");

// Promote an existing user to superuser.
admin.alterUser("username", null, AuthAdmin.UserOption.SUPERUSER);

// Change the authentication methods of an existing user.
admin.alterUser("username", null, ImmutableSet.of(AuthAdmin.AuthenticationMethod.OIDC));
note

When null is passed as the password in alterUser, the password is not changed. When an empty string is passed, the password is deleted.

Drop a user​

You can drop (delete) a user as follows:

admin.dropUser("username");

When you drop a user, ScalarDB Cluster also deletes the privileges that were granted directly to the user and removes the user's role grants. The roles themselves are not deleted.

Get a user​

You can get an existing user as follows:

Optional<AuthAdmin.User> user = admin.getUser("username");
user.ifPresent(u -> {
System.out.println("Name: " + u.getName());
System.out.println("Superuser: " + u.isSuperuser());
System.out.println("Authentication methods: " + u.getAuthenticationMethods());
});
Get all users​

You can get all users as follows:

List<AuthAdmin.User> users = admin.getUsers();
for (AuthAdmin.User user : users) {
System.out.println("Name: " + user.getName());
System.out.println("Superuser: " + user.isSuperuser());
}
Get the current user​

You can get the currently logged-in user as follows:

AuthAdmin.User currentUser = admin.getCurrentUser();
System.out.println("Current user: " + currentUser.getName());
System.out.println("Superuser: " + currentUser.isSuperuser());

Manage privileges for users​

The following operations let you grant, revoke, and check privileges for users.

The Privilege enum defines the following values:

PrivilegeDescription
READRead operations (Get and Scan)
WRITEWrite operations (Put, Insert, Upsert, Update)
DELETEDelete operations (Delete)
CREATECreating tables and indexes
DROPDropping tables and indexes
TRUNCATETruncating tables
ALTERAltering tables
GRANTGranting and revoking privileges on tables
note

These privilege names differ from the SQL-level privilege names (such as SELECT, INSERT, and UPDATE) used by DCL in ScalarDB SQL. When using the Java API directly, use the Privilege enum values listed above.

Grant privileges to a user​

You can grant privileges to a user for all tables in a namespace or for a specific table as follows:

// Grant READ and WRITE privileges to a user for all tables in a namespace.
admin.grant("username", "namespace", AuthAdmin.Privilege.READ, AuthAdmin.Privilege.WRITE);

// Grant READ and WRITE privileges to a user for a specific table.
admin.grant("username", "namespace", "table", AuthAdmin.Privilege.READ, AuthAdmin.Privilege.WRITE);
Revoke privileges from a user​

You can revoke privileges from a user for all tables in a namespace or for a specific table as follows:

// Revoke READ privilege from a user for all tables in a namespace.
admin.revoke("username", "namespace", AuthAdmin.Privilege.READ);

// Revoke READ privilege from a user for a specific table.
admin.revoke("username", "namespace", "table", AuthAdmin.Privilege.READ);
note

A privilege can only be revoked at the scope it was granted at. Revoking a privilege at a scope where it wasn't granted is a no-op—it doesn't raise an error, and it doesn't remove the privilege granted at a different scope.

For example, revoking a READ privilege on a specific table has no effect if that privilege was granted at the namespace level. Because table-level privilege checks also consider namespace-level privileges, as described in Check if a user has a privilege, a namespace-wide grant continues to grant access to the table even after a table-scoped revoke.

Get privileges for a user​

You can get the privileges a user has for all tables in a namespace or for a specific table as follows:

// Get privileges for a user for all tables in a namespace.
Set<AuthAdmin.Privilege> nsPrivileges = admin.getPrivileges("username", "namespace");

// Get privileges for a user for a specific table.
Set<AuthAdmin.Privilege> tablePrivileges = admin.getPrivileges("username", "namespace", "table");
note

getPrivileges() returns directly granted privileges only. It doesn't include role-inherited privileges, namespace-level privileges that apply to a table, or superuser access. To check a user's effective access, use hasPrivilege().

Check if a user has a privilege​

You can check whether a user has a specific privilege for a namespace or for a specific table as follows:

// Check if a user has READ privilege on the namespace.
boolean hasNsPrivilege = admin.hasPrivilege("username", "namespace", AuthAdmin.Privilege.READ);

// Check if a user has READ privilege for a specific table.
boolean hasTablePrivilege = admin.hasPrivilege("username", "namespace", "table", AuthAdmin.Privilege.READ);
note

When checking a privilege on a specific table, both table-level and namespace-level privileges are considered, including privileges granted transitively via roles. When checking a privilege on a namespace, only namespace-level privileges (including those granted transitively via roles) are considered. Superusers always return true for any privilege check.

Manage roles and privileges​

The following operations let you create, modify, and retrieve roles, as well as grant and revoke roles to users and other roles.

A role is a named collection of privileges. When you grant a role to a user, the user can use all privileges of the role. When you grant a role to another role, the receiving role inherits all privileges of the granted role, which lets you build a role hierarchy. For more details about roles, see Roles.

When you grant a role to a user or another role, you can also specify the admin option. A user who has the admin option for a role can grant that role to other users and roles, and can revoke it from them. If you grant a role to another role with the admin option, all users who have the receiving role, directly or through a role hierarchy, also have the admin option for the granted role. The admin option applies only to the role itself. It doesn't allow the user to grant privileges to the role.

The following table shows who can execute each role-related operation.

OperationWho can execute it
createRole(), dropRole()Superusers
grantRoleToUser(), revokeRoleFromUser(), revokeAdminOptionFromUser()Superusers, or users who have the admin option for the role specified by roleName
grantRoleToRole(), revokeRoleFromRole(), revokeAdminOptionFromRole()Superusers, or users who have the admin option for the role specified by memberRoleName
grantPrivilegeToRole(), revokePrivilegeFromRole()Superusers, or users who have the GRANT privilege and the privilege that is granted or revoked
getRole(), getRoles(), getRolesForUser(), getGranteeUsersForRole(), getRolePrivileges()Any authenticated user
note

If the current user is authenticated but doesn't meet the requirements, the operation throws ExecutionException for which isAuthorizationError() returns true. For details, see Authentication and authorization exceptions.

Create a role​

You can create a role as follows:

admin.createRole("rolename");

If the role already exists, IllegalArgumentException is thrown.

Drop a role​

You can drop (delete) a role as follows:

admin.dropRole("rolename");

When you drop a role, ScalarDB Cluster also removes the role from all users and roles that it was granted to, and deletes all privileges that were granted to the role. If the role doesn't exist, IllegalArgumentException is thrown.

Get a role​

You can get an existing role, including the roles that are granted to it, as follows:

Optional<AuthAdmin.Role> role = admin.getRole("rolename");
role.ifPresent(r -> {
System.out.println("Name: " + r.getName());
for (AuthAdmin.GrantedRoleRef grantedRole : r.getGrantedRoles()) {
System.out.println("Granted role: " + grantedRole.getName());
System.out.println("Has admin option: " + grantedRole.hasAdminOption());
}
});
Get all roles​

You can get all roles, including the roles that are granted to each role, as follows:

List<AuthAdmin.Role> roles = admin.getRoles();
for (AuthAdmin.Role role : roles) {
System.out.println("Name: " + role.getName());
for (AuthAdmin.GrantedRoleRef grantedRole : role.getGrantedRoles()) {
System.out.println(" Granted role: " + grantedRole.getName() + " (admin option: " + grantedRole.hasAdminOption() + ")");
}
}
Get roles for a user​

You can get the roles granted to a user as follows:

List<AuthAdmin.RoleForUser> roles = admin.getRolesForUser("username");
for (AuthAdmin.RoleForUser role : roles) {
System.out.println("Role: " + role.getName());
System.out.println("Has admin option: " + role.hasAdminOptionOnUser());
}
Grant a role to a user​

You can grant a role to a user as follows:

// Grant a role to a user without admin option.
admin.grantRoleToUser("username", "rolename", false);

// Grant a role to a user with admin option (the user can then grant this role to others).
admin.grantRoleToUser("username", "rolename", true);
note

Granting a role that is already granted to the user doesn't raise an error. If the role was previously granted without the admin option and you grant it again with the admin option, the admin option is added. Granting a role again without the admin option doesn't remove an existing admin option. To remove the admin option, use revokeAdminOptionFromUser().

Revoke a role from a user​

You can revoke a role from a user as follows:

admin.revokeRoleFromUser("username", "rolename");
Revoke admin option from a user for a role​

You can revoke only the admin option (without revoking the role itself) from a user as follows:

admin.revokeAdminOptionFromUser("username", "rolename");
Get grantee users for a role​

You can get the users who have been granted a specific role as follows:

List<AuthAdmin.GranteeUserRef> grantees = admin.getGranteeUsersForRole("rolename");
for (AuthAdmin.GranteeUserRef grantee : grantees) {
System.out.println("User: " + grantee.getName());
System.out.println("Has admin option: " + grantee.hasAdminOption());
}
Grant a member role to a role​

You can grant a member role to a role so that users with that role inherit the member role's privileges as follows:

// Grant a member role to a role without admin option.
admin.grantRoleToRole("rolename", "memberrolename", false);

// Grant a member role to a role with admin option.
admin.grantRoleToRole("rolename", "memberrolename", true);
note

As with granting a role to a user, granting a member role that is already granted to the role doesn't raise an error, and granting it again with the admin option adds the admin option. If the grant would create a cycle in the role hierarchy (for example, granting role_a to role_b when role_b is already granted to role_a), the operation fails.

Revoke a member role from a role​

You can revoke a member role from a role as follows:

admin.revokeRoleFromRole("rolename", "memberrolename");
Revoke admin option from a role for another role​

You can revoke only the admin option for a role-to-role grant as follows:

admin.revokeAdminOptionFromRole("rolename", "memberrolename");
Grant privileges to a role​

You can grant privileges to a role for all tables in a namespace or for a specific table as follows:

// Grant READ and WRITE privileges to a role for all tables in a namespace.
admin.grantPrivilegeToRole("rolename", "namespace", AuthAdmin.Privilege.READ, AuthAdmin.Privilege.WRITE);

// Grant READ and WRITE privileges to a role for a specific table.
admin.grantPrivilegeToRole("rolename", "namespace", "table", AuthAdmin.Privilege.READ, AuthAdmin.Privilege.WRITE);
note

Users who aren't superusers can grant only privileges that they have, and they must also have the GRANT privilege. Having the admin option for a role doesn't allow a user to grant privileges to that role.

Revoke privileges from a role​

You can revoke privileges from a role for all tables in a namespace or for a specific table as follows:

// Revoke READ privilege from a role for all tables in a namespace.
admin.revokePrivilegeFromRole("rolename", "namespace", AuthAdmin.Privilege.READ);

// Revoke READ privilege from a role for a specific table.
admin.revokePrivilegeFromRole("rolename", "namespace", "table", AuthAdmin.Privilege.READ);
note

A privilege can only be revoked at the scope it was granted at. Revoking a privilege at a scope where it wasn't granted is a no-op—it doesn't raise an error, and it doesn't remove the privilege granted at a different scope.

For example, revoking a READ privilege on a specific table has no effect if that privilege was granted at the namespace level. Because table-level privilege checks also consider namespace-level privileges, as described in Check if a user has a privilege, a namespace-wide grant continues to grant access to the table even after a table-scoped revoke.

Get privileges for a role​

You can get the privileges a role has for all tables in a namespace or for a specific table as follows:

// Get privileges for a role for all tables in a namespace.
Set<AuthAdmin.Privilege> nsPrivileges = admin.getRolePrivileges("rolename", "namespace");

// Get privileges for a role for a specific table.
Set<AuthAdmin.Privilege> tablePrivileges = admin.getRolePrivileges("rolename", "namespace", "table");
note

getRolePrivileges() returns privileges granted directly to the role only. It doesn't include privileges inherited from member roles or namespace-level privileges that apply to a table.

Example: Manage privileges by using roles​

The following example creates a role, grants privileges to the role, and then grants the role to a user. This example assumes that the user user1 and the table ns.tbl already exist. Run this example as a superuser.

// Create a role.
admin.createRole("analyst");

// Grant privileges to the role.
admin.grantPrivilegeToRole("analyst", "ns", "tbl", AuthAdmin.Privilege.READ);

// Grant the role to a user.
admin.grantRoleToUser("user1", "analyst", false);

// Confirm that the user has the READ privilege on the table through the role.
boolean canRead = admin.hasPrivilege("user1", "ns", "tbl", AuthAdmin.Privilege.READ); // true

// Revoke the role from the user. The user loses the privileges that were provided by the role.
admin.revokeRoleFromUser("user1", "analyst");

For details about handling failures from these APIs, see Authentication and authorization exceptions.

Transaction API​

This section explains how to execute transactional operations by using the Transaction API in ScalarDB.

Get a DistributedTransactionManager instance​

You first need to get a DistributedTransactionManager instance to execute transactional operations.

To get a DistributedTransactionManager instance, you can use TransactionFactory as follows:

TransactionFactory transactionFactory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
DistributedTransactionManager transactionManager = transactionFactory.getTransactionManager();

After you have executed all transactional operations, you should close the DistributedTransactionManager instance as follows:

transactionManager.close();

Execute transactions​

This subsection explains how to execute transactions with multiple CRUD operations.

Begin or start a transaction​

Before executing transactional CRUD operations, you need to begin or start a transaction.

You can begin a transaction as follows:

// Begin a transaction.
DistributedTransaction transaction = transactionManager.begin();

Or, you can start a transaction as follows:

// Start a transaction.
DistributedTransaction transaction = transactionManager.start();

Alternatively, you can use the begin method for a transaction by specifying a transaction ID as follows:

// Begin a transaction by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.begin("<TRANSACTION_ID>");

Or, you can use the start method for a transaction by specifying a transaction ID as follows:

// Start a transaction by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.start("<TRANSACTION_ID>");
note

Specifying a transaction ID is useful when you want to link external systems to ScalarDB. Otherwise, you should use the begin() method or the start() method.

When you specify a transaction ID, make sure you specify a unique ID (for example, UUID v4) throughout the system since ScalarDB depends on the uniqueness of transaction IDs for correctness.

Begin or start a transaction in read-only mode​

You can also begin or start a transaction in read-only mode. In this case, the transaction will not allow any write operations, and ScalarDB may optimize it for read operations.

note

Using read-only transactions for read-only operations is strongly recommended to improve performance and reduce resource usage.

You can begin or start a transaction in read-only mode as follows:

// Begin a transaction in read-only mode.
DistributedTransaction transaction = transactionManager.beginReadOnly();
// Start a transaction in read-only mode.
DistributedTransaction transaction = transactionManager.startReadOnly();

Alternatively, you can use the beginReadOnly and startReadOnly methods by specifying a transaction ID as follows:

// Begin a transaction in read-only mode by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.beginReadOnly("<TRANSACTION_ID>");
// Start a transaction in read-only mode by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.startReadOnly("<TRANSACTION_ID>");
note

Specifying a transaction ID is useful when you want to link external systems to ScalarDB. Otherwise, you should use the beginReadOnly() method or the startReadOnly() method.

When you specify a transaction ID, make sure you specify a unique ID (for example, UUID v4) throughout the system since ScalarDB depends on the uniqueness of transaction IDs for correctness.

Begin or start a transaction with attributes​

You can specify a map of transaction-scoped attributes when beginning or starting a transaction. The specified attributes are merged into every operation issued within the transaction, so configuration that should apply uniformly across the whole transaction can be specified once at begin time. If an operation already has the same attribute key, the attribute on the operation takes precedence.

You can begin or start a transaction with attributes as follows:

// Specify attributes for the transaction.
Map<String, String> attributes = ...;

// Begin a transaction with attributes.
DistributedTransaction transaction = transactionManager.begin(attributes);
// Start a transaction with attributes.
DistributedTransaction transaction = transactionManager.start(attributes);

You can also specify a transaction ID together with attributes as follows:

// Begin a transaction with attributes by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.begin("<TRANSACTION_ID>", attributes);
// Start a transaction with attributes by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.start("<TRANSACTION_ID>", attributes);

The beginReadOnly and startReadOnly methods also have overloads that accept attributes:

// Begin a transaction in read-only mode with attributes.
DistributedTransaction transaction = transactionManager.beginReadOnly(attributes);
// Start a transaction in read-only mode with attributes.
DistributedTransaction transaction = transactionManager.startReadOnly(attributes);
// Begin a transaction in read-only mode with attributes by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.beginReadOnly("<TRANSACTION_ID>", attributes);
// Start a transaction in read-only mode with attributes by specifying a transaction ID.
DistributedTransaction transaction = transactionManager.startReadOnly("<TRANSACTION_ID>", attributes);

For the list of available attributes, see Operation attributes.

Join a transaction​

Deprecation notice

The join() method is deprecated as of ScalarDB 3.19 and will be removed in a future release.

Joining a transaction is for a stateful application where a transaction spans multiple client requests. In such a scenario, the application can start a transaction during the first client request. Then, in subsequent client requests, the application can join the ongoing transaction by using the join() method.

You can join an ongoing transaction that has already begun by specifying the transaction ID as follows:

// Join a transaction.
DistributedTransaction transaction = transactionManager.join("<TRANSACTION_ID>");
note

To get the transaction ID with getId(), you can specify the following:

tx.getId();

Resume a transaction​

Deprecation notice

The resume() method is deprecated as of ScalarDB 3.19 and will be removed in a future release.

Resuming a transaction is for a stateful application where a transaction spans multiple client requests. In such a scenario, the application can start a transaction during the first client request. Then, in subsequent client requests, the application can resume the ongoing transaction by using the resume() method.

You can resume an ongoing transaction that you have already begun by specifying a transaction ID as follows:

// Resume a transaction.
DistributedTransaction transaction = transactionManager.resume("<TRANSACTION_ID>");
note

To get the transaction ID with getId(), you can specify the following:

tx.getId();

Implement CRUD operations​

The following sections describe key construction and CRUD operations.

note

Although all the builders of the CRUD operations can specify consistency by using the consistency() methods, those methods are ignored. Instead, the LINEARIZABLE consistency level is always used in transactions.

Key construction​

Most CRUD operations need to specify Key objects (partition-key, clustering-key, etc.). So, before moving on to CRUD operations, the following explains how to construct a Key object.

For a single column key, you can use Key.of<TYPE_NAME>() methods to construct the key as follows:

// For a key that consists of a single column of INT.
Key key1 = Key.ofInt("col1", 1);

// For a key that consists of a single column of BIGINT.
Key key2 = Key.ofBigInt("col1", 100L);

// For a key that consists of a single column of DOUBLE.
Key key3 = Key.ofDouble("col1", 1.3d);

// For a key that consists of a single column of TEXT.
Key key4 = Key.ofText("col1", "value");

For a key that consists of two to five columns, you can use the Key.of() method to construct the key as follows. Similar to ImmutableMap.of() in Guava, you need to specify column names and values in turns:

// For a key that consists of two to five columns.
Key key1 = Key.of("col1", 1, "col2", 100L);
Key key2 = Key.of("col1", 1, "col2", 100L, "col3", 1.3d);
Key key3 = Key.of("col1", 1, "col2", 100L, "col3", 1.3d, "col4", "value");
Key key4 = Key.of("col1", 1, "col2", 100L, "col3", 1.3d, "col4", "value", "col5", false);

For a key that consists of more than five columns, we can use the builder to construct the key as follows:

// For a key that consists of more than five columns.
Key key = Key.newBuilder()
.addInt("col1", 1)
.addBigInt("col2", 100L)
.addDouble("col3", 1.3d)
.addText("col4", "value")
.addBoolean("col5", false)
.addInt("col6", 100)
.build();
Get operation​

Get is an operation to retrieve a single record specified by a primary key.

You need to create a Get object first, and then you can execute the object by using the transaction.get() method as follows:

// Create a `Get` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.projections("c1", "c2", "c3", "c4")
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.build();

// Execute the `Get` operation.
Optional<Result> result = transaction.get(get);

You can specify projections to choose which columns are returned.

Use the WHERE clause​

You can also specify arbitrary conditions by using the where() method. If the retrieved record does not match the conditions specified by the where() method, Optional.empty() will be returned. As an argument of the where() method, you can specify a condition, an AND-wise condition set, or an OR-wise condition set. After calling the where() method, you can add more conditions or condition sets by using the and() method or or() method as follows:

// Create a `Get` operation with condition sets.
Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.where(
ConditionSetBuilder.condition(ConditionBuilder.column("c1").isLessThanInt(10))
.or(ConditionBuilder.column("c1").isGreaterThanInt(20))
.build())
.and(
ConditionSetBuilder.condition(ConditionBuilder.column("c2").isLikeText("a%"))
.or(ConditionBuilder.column("c2").isLikeText("b%"))
.build())
.build();
note

In the where() condition method chain, the conditions must be an AND-wise junction of ConditionalExpression or OrConditionSet (known as conjunctive normal form) like the above example or an OR-wise junction of ConditionalExpression or AndConditionSet (known as disjunctive normal form).

For more details about available conditions and condition sets, see the ConditionBuilder and ConditionSetBuilder pages in the Javadoc.

Handle Result objects​

The Get operation and Scan operation return Result objects. The following shows how to handle Result objects.

You can get a column value of a result by using get<TYPE_NAME>("<COLUMN_NAME>") methods as follows:

// Get the BOOLEAN value of a column.
boolean booleanValue = result.getBoolean("<COLUMN_NAME>");

// Get the INT value of a column.
int intValue = result.getInt("<COLUMN_NAME>");

// Get the BIGINT value of a column.
long bigIntValue = result.getBigInt("<COLUMN_NAME>");

// Get the FLOAT value of a column.
float floatValue = result.getFloat("<COLUMN_NAME>");

// Get the DOUBLE value of a column.
double doubleValue = result.getDouble("<COLUMN_NAME>");

// Get the TEXT value of a column.
String textValue = result.getText("<COLUMN_NAME>");

// Get the BLOB value of a column as a `ByteBuffer`.
ByteBuffer blobValue = result.getBlob("<COLUMN_NAME>");

// Get the BLOB value of a column as a `byte` array.
byte[] blobValueAsBytes = result.getBlobAsBytes("<COLUMN_NAME>");

// Get the DATE value of a column as a `LocalDate`.
LocalDate dateValue = result.getDate("<COLUMN_NAME>");

// Get the TIME value of a column as a `LocalTime`.
LocalTime timeValue = result.getTime("<COLUMN_NAME>");

// Get the TIMESTAMP value of a column as a `LocalDateTime`.
LocalDateTime timestampValue = result.getTimestamp("<COLUMN_NAME>");

// Get the TIMESTAMPTZ value of a column as a `Instant`.
Instant timestampTZValue = result.getTimestampTZ("<COLUMN_NAME>");

And if you need to check if a value of a column is null, you can use the isNull("<COLUMN_NAME>") method.

// Check if a value of a column is null.
boolean isNull = result.isNull("<COLUMN_NAME>");

For more details, see the Result page in the Javadoc.

Execute Get by using a secondary index​

You can execute a Get operation by using a secondary index.

Instead of specifying a partition key, you can specify an index key (indexed column) to use a secondary index as follows:

// Create a `Get` operation by using a secondary index.
Key indexKey = Key.ofFloat("c4", 1.23F);

Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.indexKey(indexKey)
.projections("c1", "c2", "c3", "c4")
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.build();

// Execute the `Get` operation.
Optional<Result> result = transaction.get(get);

You can also specify arbitrary conditions by using the where() method. For details, see Use the WHERE clause.

note

If the result has more than one record, transaction.get() will throw an exception. If you want to handle multiple results, see Execute Scan by using a secondary index.

Scan operation​

Scan is an operation to retrieve multiple records within a partition. You can specify clustering-key boundaries and orderings for clustering-key columns in Scan operations. To execute a Scan operation, you can use the transaction.scan() method or the transaction.getScanner() method:

  • transaction.scan():
    • This method immediately executes the given Scan operation and returns a list of all matching records. It is suitable when the result set is expected to be small enough to fit in memory.
  • transaction.getScanner():
    • This method returns a Scanner object that allows you to iterate over the result set lazily. It is useful when the result set may be large, as it avoids loading all records into memory at once.

You need to create a Scan object first, and then you can execute the object by using the transaction.scan() method or the transaction.getScanner() method as follows:

// Create a `Scan` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key startClusteringKey = Key.of("c2", "aaa", "c3", 100L);
Key endClusteringKey = Key.of("c2", "aaa", "c3", 300L);

Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.start(startClusteringKey, true) // Include startClusteringKey
.end(endClusteringKey, false) // Exclude endClusteringKey
.projections("c1", "c2", "c3", "c4")
.orderings(Scan.Ordering.desc("c2"), Scan.Ordering.asc("c3"))
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.limit(10)
.build();

// Execute the `Scan` operation by using the `transaction.scan()` method.
List<Result> results = transaction.scan(scan);

// Or, execute the `Scan` operation by using the `transaction.getScanner()` method.
try (TransactionCrudOperable.Scanner scanner = transaction.getScanner(scan)) {
// Fetch the next result from the scanner
Optional<Result> result = scanner.one();

// Fetch all remaining results from the scanner
List<Result> allResults = scanner.all();
}

You can omit the clustering-key boundaries or specify either a start boundary or an end boundary. If you don't specify orderings, you will get results ordered by the clustering order that you defined when creating the table.

In addition, you can specify projections to choose which columns are returned and use limit to specify the number of records to return in Scan operations.

Use the WHERE clause​

You can also specify arbitrary conditions by using the where() method to filter scanned records. As an argument of the where() method, you can specify a condition, an AND-wise condition set, or an OR-wise condition set. After calling the where() method, you can add more conditions or condition sets by using the and() method or or() method as follows:

// Create a `Scan` operation with condition sets.
Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.all()
.where(
ConditionSetBuilder.condition(ConditionBuilder.column("c1").isLessThanInt(10))
.or(ConditionBuilder.column("c1").isGreaterThanInt(20))
.build())
.and(
ConditionSetBuilder.condition(ConditionBuilder.column("c2").isLikeText("a%"))
.or(ConditionBuilder.column("c2").isLikeText("b%"))
.build())
.limit(10)
.build();
note

In the where() condition method chain, the conditions must be an AND-wise junction of ConditionalExpression or OrConditionSet (known as conjunctive normal form) like the above example or an OR-wise junction of ConditionalExpression or AndConditionSet (known as disjunctive normal form).

For more details about available conditions and condition sets, see the ConditionBuilder and ConditionSetBuilder pages in the Javadoc.

Execute Scan by using a secondary index​

You can execute a Scan operation by using a secondary index.

Instead of specifying a partition key, you can specify an index key (indexed column) to use a secondary index as follows:

// Create a `Scan` operation by using a secondary index.
Key indexKey = Key.ofFloat("c4", 1.23F);

Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.indexKey(indexKey)
.projections("c1", "c2", "c3", "c4")
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.limit(10)
.build();

// Execute the `Scan` operation.
List<Result> results = transaction.scan(scan);

You can also specify arbitrary conditions using the where() method. For details, see Use the WHERE clause.

note

You can't specify clustering-key boundaries and orderings in Scan by using a secondary index.

Execute cross-partition Scan without specifying a partition key to retrieve all the records of a table​

You can execute a Scan operation across all partitions, which we call cross-partition scan, without specifying a partition key by enabling the following configuration in the ScalarDB properties file.

scalar.db.cross_partition_scan.enabled=true
warning

For non-JDBC databases, transactions could be executed at read-committed snapshot isolation (SNAPSHOT), which is a lower isolation level, even if you enable cross-partition scan with the SERIALIZABLE isolation level. When using non-JDBC databases, use cross-partition scan only if consistency does not matter for your transactions.

Instead of calling the partitionKey() method in the builder, you can call the all() method to scan a table without specifying a partition key as follows:

// Create a `Scan` operation without specifying a partition key.
Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.all()
.projections("c1", "c2", "c3", "c4")
.limit(10)
.build();

// Execute the `Scan` operation.
List<Result> results = transaction.scan(scan);
note

You can't specify any orderings in cross-partition Scan when using non-JDBC databases. For details on how to use cross-partition Scan with filtering or ordering, see Execute cross-partition Scan with filtering and ordering.

Execute cross-partition Scan with filtering and ordering​

By enabling the cross-partition scan option with filtering and ordering as follows, you can execute a cross-partition Scan operation with flexible conditions and orderings:

scalar.db.cross_partition_scan.enabled=true
scalar.db.cross_partition_scan.filtering.enabled=true
scalar.db.cross_partition_scan.ordering.enabled=true
note

You can't enable scalar.db.cross_partition_scan.ordering in non-JDBC databases.

You can call the where() and orderings() methods after calling the all() method to specify arbitrary conditions and orderings as follows:

// Create a `Scan` operation with arbitrary conditions and orderings.
Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.all()
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.projections("c1", "c2", "c3", "c4")
.orderings(Scan.Ordering.desc("c3"), Scan.Ordering.asc("c4"))
.limit(10)
.build();

// Execute the `Scan` operation.
List<Result> results = transaction.scan(scan);

For details about the WHERE clause, see Use the WHERE clause.

Enable cross-partition scan per operation​

Instead of enabling cross-partition scan globally through the configurations above, you can enable it for a specific operation by setting cross-partition scan attributes on the operation. When set on the operation, the attribute takes precedence over the corresponding configuration value for that operation.

The following example enables cross-partition scan, filtering, and ordering for a specific operation:

// Enable cross-partition scan for this specific `Scan` operation.
Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.all()
.where(ConditionBuilder.column("c1").isNotEqualToInt(10))
.orderings(Scan.Ordering.asc("c3"))
.attribute(DatabaseOperationAttributes.CROSS_PARTITION_SCAN_ENABLED, "true")
.attribute(DatabaseOperationAttributes.CROSS_PARTITION_SCAN_FILTERING_ENABLED, "true")
.attribute(DatabaseOperationAttributes.CROSS_PARTITION_SCAN_ORDERING_ENABLED, "true")
.build();

For details on these attributes, see Cross-partition scan attributes.

Put operation​
note

The Put operation is deprecated as of ScalarDB 3.13 and will be removed in a future release. Instead of using the Put operation, use the Insert operation, the Upsert operation, or the Update operation.

Put is an operation to put a record specified by a primary key. The operation behaves as an upsert operation for a record, in which the operation updates the record if the record exists or inserts the record if the record does not exist.

note

When you update an existing record, you need to read the record by using Get or Scan before using a Put operation. Otherwise, the operation will fail due to a conflict. This occurs because of the specification of ScalarDB to manage transactions properly. Instead of reading the record explicitly, you can enable implicit pre-read. For details, see Enable implicit pre-read for Put operations.

You need to create a Put object first, and then you can execute the object by using the transaction.put() method as follows:

// Create a `Put` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Put` operation.
transaction.put(put);

You can also put a record with null values as follows:

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", null)
.doubleValue("c5", null)
.build();
Enable implicit pre-read for Put operations​

In Consensus Commit, an application must read a record before mutating the record with Put and Delete operations to obtain the latest states of the record if the record exists. Instead of reading the record explicitly, you can enable implicit pre-read. By enabling implicit pre-read, if an application does not read the record explicitly in a transaction, ScalarDB will read the record on behalf of the application before committing the transaction.

You can enable implicit pre-read for a Put operation by specifying enableImplicitPreRead() in the Put operation builder as follows:

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.enableImplicitPreRead()
.build();
note

If you are certain that a record you are trying to mutate does not exist, you should not enable implicit pre-read for the Put operation for better performance. For example, if you load initial data, you should not enable implicit pre-read. A Put operation without implicit pre-read is faster than Put operation with implicit pre-read because the operation skips an unnecessary read.

Insert operation​

Insert is an operation to insert an entry into the underlying storage through a transaction. If the entry already exists, a conflict error will occur.

You need to create an Insert object first, and then you can execute the object by using the transaction.insert() method as follows:

// Create an `Insert` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Insert insert =
Insert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Insert` operation.
transaction.insert(insert);
Upsert operation​

Upsert is an operation to insert an entry into or update an entry in the underlying storage through a transaction. If the entry already exists, it will be updated; otherwise, the entry will be inserted.

You need to create an Upsert object first, and then you can execute the object by using the transaction.upsert() method as follows:

// Create an `Upsert` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Upsert upsert =
Upsert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Upsert` operation.
transaction.upsert(upsert);
Update operation​

Update is an operation to update an entry in the underlying storage through a transaction. If the entry does not exist, the operation will not make any changes.

You need to create an Update object first, and then you can execute the object by using the transaction.update() method as follows:

// Create an `Update` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Update update =
Update.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Update` operation.
transaction.update(update);
Delete operation​

Delete is an operation to delete a record specified by a primary key.

note

When you delete a record, you don't have to read the record beforehand because implicit pre-read is always enabled for Delete operations.

You need to create a Delete object first, and then you can execute the object by using the transaction.delete() method as follows:

// Create a `Delete` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.build();

// Execute the `Delete` operation.
transaction.delete(delete);
Put, Delete, and Update with a condition​

You can write arbitrary conditions (for example, a bank account balance must be equal to or more than zero) that you require a transaction to meet before being committed by implementing logic that checks the conditions in the transaction. Alternatively, you can write simple conditions in a mutation operation, such as Put, Delete, and Update.

When a Put, Delete, or Update operation includes a condition, the operation is executed only if the specified condition is met. If the condition is not met when the operation is executed, an exception called UnsatisfiedConditionException will be thrown.

note

When you specify a condition in a Put operation, you need to read the record beforehand or enable implicit pre-read.

Conditions for Put​

You can specify a condition in a Put operation as follows:

// Build a condition.
MutationCondition condition =
ConditionBuilder.putIf(ConditionBuilder.column("c4").isEqualToFloat(0.0F))
.and(ConditionBuilder.column("c5").isEqualToDouble(0.0))
.build();

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.condition(condition) // condition
.build();

// Execute the `Put` operation.
transaction.put(put);

In addition to using the putIf condition, you can specify the putIfExists and putIfNotExists conditions as follows:

// Build a `putIfExists` condition.
MutationCondition putIfExistsCondition = ConditionBuilder.putIfExists();

// Build a `putIfNotExists` condition.
MutationCondition putIfNotExistsCondition = ConditionBuilder.putIfNotExists();
Conditions for Delete​

You can specify a condition in a Delete operation as follows:

// Build a condition.
MutationCondition condition =
ConditionBuilder.deleteIf(ConditionBuilder.column("c4").isEqualToFloat(0.0F))
.and(ConditionBuilder.column("c5").isEqualToDouble(0.0))
.build();

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.condition(condition) // condition
.build();

// Execute the `Delete` operation.
transaction.delete(delete);

In addition to using the deleteIf condition, you can specify the deleteIfExists condition as follows:

// Build a `deleteIfExists` condition.
MutationCondition deleteIfExistsCondition = ConditionBuilder.deleteIfExists();
Conditions for Update​

You can specify a condition in an Update operation as follows:

// Build a condition.
MutationCondition condition =
ConditionBuilder.updateIf(ConditionBuilder.column("c4").isEqualToFloat(0.0F))
.and(ConditionBuilder.column("c5").isEqualToDouble(0.0))
.build();

Update update =
Update.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.condition(condition) // condition
.build();

// Execute the `Update` operation.
transaction.update(update);

In addition to using the updateIf condition, you can specify the updateIfExists condition as follows:

// Build a `updateIfExists` condition.
MutationCondition updateIfExistsCondition = ConditionBuilder.updateIfExists();
Mutate operation​

Mutate is an operation to execute multiple mutations for Put, Insert, Upsert, Update, and Delete.

You need to create mutation objects first, and then you can execute the mutations by using the transaction.mutate() method as follows:

// Create `Put` and `Delete` operations.
Key partitionKey = Key.ofInt("c1", 10);

Key clusteringKeyForPut = Key.of("c2", "aaa", "c3", 100L);

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForPut)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

Key clusteringKeyForDelete = Key.of("c2", "bbb", "c3", 200L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForDelete)
.build();

// Execute the mutations.
transaction.mutate(Arrays.asList(put, delete));
Batch operation​

Batch is an operation to execute multiple operations for Get, Scan, Put, Insert, Upsert, Update, and Delete.

You need to create operation objects first, and then you can execute the operations by using the transaction.batch() method as follows:

// Create operation objects.
Key partitionKey = Key.ofInt("c1", 10);

Key clusteringKeyForGet = Key.of("c2", "aaa", "c3", 100L);

Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForGet)
.build();

Scan scan = Scan.newBuilder().namespace("ns").table("tbl").partitionKey(partitionKey).build();

Key clusteringKeyForInsert = Key.of("c2", "bbb", "c3", 200L);

Insert insert =
Insert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForInsert)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

Key clusteringKeyForDelete = Key.of("c2", "ccc", "c3", 300L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForDelete)
.build();

// Execute the operations.
List<BatchResult> batchResults = transaction.batch(Arrays.asList(get, scan, insert, delete));

Optional<Result> getResult = batchResults.get(0).getGetResult(); // Result of the get

...

List<Result> scanResult = batchResults.get(1).getScanResult(); // Result of the scan

...
Default namespace for CRUD operations​

A default namespace for all CRUD operations can be set by using a property in the ScalarDB configuration.

scalar.db.default_namespace_name=<NAMESPACE_NAME>

Any operation that does not specify a namespace will use the default namespace set in the configuration.

// This operation will target the default namespace.
Scan scanUsingDefaultNamespace =
Scan.newBuilder()
.table("tbl")
.all()
.build();
// This operation will target the "ns" namespace.
Scan scanUsingSpecifiedNamespace =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.all()
.build();
Operation attributes​

An operation attribute is a key-value pair that can be used to store additional information about an operation. You can set operation attributes either on individual operations by using the attribute() or attributes() method in the operation builder as operation-scoped attributes, or at transaction begin time as transaction-scoped attributes that are merged into every operation in the transaction. If the same attribute is set at both levels, the operation-scoped value takes precedence over the transaction-scoped value. For details on transaction-scoped attributes, see Begin or start a transaction with attributes.

The following examples show how to set operation attributes on individual operations:

// Set operation attributes in the `Get` operation.
Get get = Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

// Set operation attributes in the `Scan` operation.
Scan scan = Scan.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.projections("c1", "c2", "c3", "c4")
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

// Set operation attributes in the `Insert` operation.
Insert insert = Insert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

// Set operation attributes in the `Upsert` operation.
Upsert upsert = Upsert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

// Set operation attributes in the `Update` operation.
Update update = Update.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

// Set operation attributes in the `Delete` operation.
Delete delete = Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.attribute("attribute1", "value1")
.attributes(ImmutableMap.of("attribute2", "value2", "attribute3", "value3"))
.build();

The following attributes are available. Setting an attribute on an operation that does not support it has no effect.

Cross-partition scan attributes​

The following attributes apply to a cross-partition Scan operation (a Scan operation without a partition key). When set on the operation, they take precedence over the corresponding scalar.db.cross_partition_scan.* configuration values for that operation. For details on the configurations, see Cross-partition scan configurations.

  • db-cross-partition-scan-enabled: Whether cross-partition scan is enabled for the operation. The value must be true or false.
  • db-cross-partition-scan-filtering-enabled: Whether cross-partition scan with filtering is enabled for the operation. The value must be true or false.
  • db-cross-partition-scan-ordering-enabled: Whether cross-partition scan with ordering is enabled for the operation. The value must be true or false. This attribute is available only for JDBC databases.

You can use the DatabaseOperationAttributes utility class to set these attributes.

Consensus Commit attributes​

The following attribute applies to transactions executed under Consensus Commit.

  • cc-transaction-isolation: The isolation level for the transaction. The value must be SNAPSHOT, SERIALIZABLE, or READ_COMMITTED. When set, this overrides the configured default isolation level for the transaction. For details on isolation levels, see Isolation levels.
warning

Unlike the other operation attributes, cc-transaction-isolation cannot be applied per operation within a transaction. The attribute is interpreted only at transaction begin time. To use it, either specify it when beginning or starting a transaction with attributes, or, when executing transactions without beginning or starting a transaction, set it on the operation, which ScalarDB then uses for the transaction that it implicitly begins.

You can use the ConsensusCommitOperationAttributes utility class to set this attribute.

Commit a transaction​

After executing CRUD operations, you need to commit a transaction to finish it.

You can commit a transaction as follows:

// Commit a transaction.
transaction.commit();

Roll back or abort a transaction​

If an error occurs when executing a transaction, you can roll back or abort the transaction.

You can roll back a transaction as follows:

// Roll back a transaction.
transaction.rollback();

Or, you can abort a transaction as follows:

// Abort a transaction.
transaction.abort();

For details about how to handle exceptions in ScalarDB, see How to handle exceptions.

Execute transactions without beginning or starting a transaction​

You can execute transactional operations without beginning or starting a transaction. In this case, ScalarDB will automatically begin a transaction before executing the operations and commit the transaction after executing the operations. This section explains how to execute transactions without beginning or starting a transaction.

Execute Get operation​

Get is an operation to retrieve a single record specified by a primary key.

You need to create a Get object first, and then you can execute the object by using the transactionManager.get() method as follows:

// Create a `Get` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.projections("c1", "c2", "c3", "c4")
.build();

// Execute the `Get` operation.
Optional<Result> result = transactionManager.get(get);

For details about the Get operation, see Get operation.

Execute Scan operation​

Scan is an operation to retrieve multiple records within a partition. You can specify clustering-key boundaries and orderings for clustering-key columns in Scan operations. To execute a Scan operation, you can use the transactionManager.scan() method or the transactionManager.getScanner() method:

  • transactionManager.scan():
    • This method immediately executes the given Scan operation and returns a list of all matching records. It is suitable when the result set is expected to be small enough to fit in memory.
  • transactionManager.getScanner():
    • This method returns a Scanner object that allows you to iterate over the result set lazily. It is useful when the result set may be large, as it avoids loading all records into memory at once.

You need to create a Scan object first, and then you can execute the object by using the transactionManager.scan() method or the transactionManager.getScanner() method as follows:

// Create a `Scan` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key startClusteringKey = Key.of("c2", "aaa", "c3", 100L);
Key endClusteringKey = Key.of("c2", "aaa", "c3", 300L);

Scan scan =
Scan.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.start(startClusteringKey, true) // Include startClusteringKey
.end(endClusteringKey, false) // Exclude endClusteringKey
.projections("c1", "c2", "c3", "c4")
.orderings(Scan.Ordering.desc("c2"), Scan.Ordering.asc("c3"))
.limit(10)
.build();

// Execute the `Scan` operation by using the `transactionManager.scan()` method.
List<Result> results = transactionManager.scan(scan);

// Or, execute the `Scan` operation by using the `transactionManager.getScanner()` method.
try (TransactionManagerCrudOperable.Scanner scanner = transactionManager.getScanner(scan)) {
// Fetch the next result from the scanner
Optional<Result> result = scanner.one();

// Fetch all remaining results from the scanner
List<Result> allResults = scanner.all();
}

For details about the Scan operation, see Scan operation.

Execute Put operation​

note

The Put operation is deprecated as of ScalarDB 3.13 and will be removed in a future release. Instead of using the Put operation, use the Insert operation, the Upsert operation, or the Update operation.

You need to create a Put object first, and then you can execute the object by using the transactionManager.put() method as follows:

// Create a `Put` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Put` operation.
transactionManager.put(put);

For details about the Put operation, see Put operation.

Execute Insert operation​

Insert is an operation to insert an entry into the underlying storage through a transaction. If the entry already exists, a conflict error will occur.

You need to create an Insert object first, and then you can execute the object by using the transactionManager.insert() method as follows:

// Create an `Insert` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Insert insert =
Insert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Insert` operation.
transactionManager.insert(insert);

For details about the Insert operation, see Insert operation.

Execute Upsert operation​

Upsert is an operation to insert an entry into or update an entry in the underlying storage through a transaction. If the entry already exists, it will be updated; otherwise, the entry will be inserted.

You need to create an Upsert object first, and then you can execute the object by using the transactionManager.upsert() method as follows:

// Create an `Upsert` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Upsert upsert =
Upsert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Upsert` operation.
transactionManager.upsert(upsert);

For details about the Upsert operation, see Upsert operation.

Execute Update operation​

Update is an operation to update an entry in the underlying storage through a transaction. If the entry does not exist, the operation will not make any changes.

You need to create an Update object first, and then you can execute the object by using the transactionManager.update() method as follows:

// Create an `Update` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Update update =
Update.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

// Execute the `Update` operation.
transactionManager.update(update);

For details about the Update operation, see Update operation.

Execute Delete operation​

Delete is an operation to delete a record specified by a primary key.

You need to create a Delete object first, and then you can execute the object by using the transactionManager.delete() method as follows:

// Create a `Delete` operation.
Key partitionKey = Key.ofInt("c1", 10);
Key clusteringKey = Key.of("c2", "aaa", "c3", 100L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKey)
.build();

// Execute the `Delete` operation.
transactionManager.delete(delete);

For details about the Delete operation, see Delete operation.

Execute Mutate operation​

Mutate is an operation to execute multiple mutations (Put, Insert, Upsert, Update, and Delete operations).

You need to create mutation objects first, and then you can execute the mutations by using the transactionManager.mutate() method as follows:

// Create `Put` and `Delete` operations.
Key partitionKey = Key.ofInt("c1", 10);

Key clusteringKeyForPut = Key.of("c2", "aaa", "c3", 100L);

Put put =
Put.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForPut)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

Key clusteringKeyForDelete = Key.of("c2", "bbb", "c3", 200L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForDelete)
.build();

// Execute the mutations.
transactionManager.mutate(Arrays.asList(put, delete));

For details about the Mutate operation, see Mutate operation.

In addition, for details about how to handle exceptions in ScalarDB, see How to handle exceptions.

Execute Batch operation​

Batch is an operation to execute multiple operations for Get, Scan, Put, Insert, Upsert, Update, and Delete.

You need to create operation objects first, and then you can execute the operations by using the transactionManager.batch() method as follows:

// Create operation objects.
Key partitionKey = Key.ofInt("c1", 10);

Key clusteringKeyForGet = Key.of("c2", "aaa", "c3", 100L);

Get get =
Get.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForGet)
.build();

Scan scan = Scan.newBuilder().namespace("ns").table("tbl").partitionKey(partitionKey).build();

Key clusteringKeyForInsert = Key.of("c2", "bbb", "c3", 200L);

Insert insert =
Insert.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForInsert)
.floatValue("c4", 1.23F)
.doubleValue("c5", 4.56)
.build();

Key clusteringKeyForDelete = Key.of("c2", "ccc", "c3", 300L);

Delete delete =
Delete.newBuilder()
.namespace("ns")
.table("tbl")
.partitionKey(partitionKey)
.clusteringKey(clusteringKeyForDelete)
.build();

// Execute the operations.
List<BatchResult> batchResults = transactionManager.batch(Arrays.asList(get, scan, insert, delete));

Optional<Result> getResult = batchResults.get(0).getGetResult(); // Result of the get

...

List<Result> scanResult = batchResults.get(1).getScanResult(); // Result of the scan

...

For details about the Batch operation, see Batch operation.

Microservice Transaction API​

This section explains how to execute a transaction that spans multiple processes by using the Microservice Transaction API in ScalarDB. The API consists of the GlobalTransactionManager, GlobalTransaction, and BranchTransaction interfaces.

A global transaction is a single transaction that is made up of one or more branches. Each branch runs in its own process (for example, one process per microservice) and executes CRUD operations on its own data. The transaction is begun once, each branch joins it by using the transaction ID, and the transaction is then committed or rolled back as a whole, so the work of all the branches either takes effect together or not at all.

A global transaction has two kinds of handles:

  • GlobalTransaction: The overall handle that drives the outcome of the transaction. This handle does not execute CRUD operations.
  • BranchTransaction: The handle for one branch of the transaction, which executes the CRUD operations for that branch.

For more details, see the GlobalTransactionManager, GlobalTransaction, and BranchTransaction pages in the Javadoc.

Get a GlobalTransactionManager instance​

You first need to get a GlobalTransactionManager instance to execute global transactions. Every process that takes part in a global transaction needs its own instance.

To get a GlobalTransactionManager instance, you can use TransactionFactory as follows:

TransactionFactory transactionFactory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
GlobalTransactionManager transactionManager = transactionFactory.getGlobalTransactionManager();

After you have executed all global transactions, you should close the GlobalTransactionManager instance as follows:

transactionManager.close();

Execute global transactions​

This subsection explains how to execute a global transaction. One process begins the transaction, each process begins its own branch and executes CRUD operations in it, each branch is ended, and then the process that began the transaction commits or rolls it back.

Begin or start a global transaction​

Only one process begins the global transaction, and that same process later commits or rolls it back. The other processes only begin their own branches.

You can begin a global transaction as follows:

// Begin a global transaction.
GlobalTransaction globalTransaction = transactionManager.begin();

Or, you can start a global transaction as follows:

// Start a global transaction.
GlobalTransaction globalTransaction = transactionManager.start();

ScalarDB generates the transaction ID for you. Because every branch needs that ID, you should read it by using getId() and pass it to the other processes as follows:

// Get the ID of the global transaction.
String transactionId = globalTransaction.getId();
Begin or start a global transaction in read-only mode​

You can also begin or start a global transaction in read-only mode. In this case, the transaction will not allow any write operations, and ScalarDB may optimize it for read operations.

note

Using read-only transactions for read-only operations is strongly recommended to improve performance and reduce resource usage.

You can begin or start a global transaction in read-only mode as follows:

// Begin a global transaction in read-only mode.
GlobalTransaction globalTransaction = transactionManager.beginReadOnly();
// Start a global transaction in read-only mode.
GlobalTransaction globalTransaction = transactionManager.startReadOnly();
Begin or start a global transaction with attributes​

You can specify a map of transaction-scoped attributes when beginning or starting a global transaction. The specified attributes are merged into every operation issued within the transaction, so configuration that should apply uniformly across the whole transaction can be specified once at begin time. If an operation already has the same attribute key, the attribute on the operation takes precedence.

You can begin or start a global transaction with attributes as follows:

// Specify attributes for the global transaction.
Map<String, String> attributes = ...;

// Begin a global transaction with attributes.
GlobalTransaction globalTransaction = transactionManager.begin(attributes);
// Start a global transaction with attributes.
GlobalTransaction globalTransaction = transactionManager.start(attributes);

The beginReadOnly and startReadOnly methods also have overloads that accept attributes:

// Begin a global transaction in read-only mode with attributes.
GlobalTransaction globalTransaction = transactionManager.beginReadOnly(attributes);
// Start a global transaction in read-only mode with attributes.
GlobalTransaction globalTransaction = transactionManager.startReadOnly(attributes);

For the list of available attributes, see Operation attributes.

Begin or start a branch​

Each process that executes CRUD operations in the global transaction begins its own branch by specifying the ID of the global transaction.

You can begin a branch as follows:

// Begin a branch of the global transaction.
BranchTransaction branch = transactionManager.beginBranch("<TRANSACTION_ID>");

Or, you can start a branch as follows:

// Start a branch of the global transaction.
BranchTransaction branch = transactionManager.startBranch("<TRANSACTION_ID>");
note

The process that begins the global transaction also needs to begin a branch if it executes CRUD operations, since the GlobalTransaction handle does not execute CRUD operations.

To get the ID of the global transaction that a branch belongs to, you can use getId() as follows:

// Get the ID of the global transaction that this branch belongs to.
String transactionId = branch.getId();
Begin or start a branch with attributes​

You can specify a map of per-branch attributes when beginning or starting a branch. The specified attributes are attached to every operation issued on that branch. If an operation already has the same attribute key, the attribute on the operation takes precedence. Likewise, if the same attribute key is also specified for the global transaction, the per-branch attribute takes precedence. In other words, the narrower scope always takes precedence.

You can begin or start a branch with attributes as follows:

// Specify attributes for the branch.
Map<String, String> attributes = ...;

// Begin a branch with attributes.
BranchTransaction branch = transactionManager.beginBranch("<TRANSACTION_ID>", attributes);
// Start a branch with attributes.
BranchTransaction branch = transactionManager.startBranch("<TRANSACTION_ID>", attributes);

For the list of available attributes, see Operation attributes.

Implement CRUD operations in a branch​

The CRUD operations for BranchTransaction are the same as the operations for DistributedTransaction. For details, see Implement CRUD operations.

Close every Scanner object that you obtain from a branch before you end the branch. For details, see End a branch.

End a branch​

After a branch has executed its last CRUD operation, you need to end the branch, declaring the outcome of its work. Ending a branch does not commit the transaction, since the outcome is still driven by the global transaction.

If the work of the branch succeeded, you can end the branch as follows:

// End the branch, declaring that its work succeeded.
branch.end(BranchTransaction.Status.SUCCESS);

If the work of the branch failed, you can end the branch as follows:

// End the branch, declaring that its work failed.
branch.end(BranchTransaction.Status.FAILURE);
warning

When ending a branch, keep the following in mind:

  • The process that runs a branch must end it exactly once, on both the success path and the failure path. A process that also drives the outcome of the transaction is not exempt, since committing or rolling back the transaction only reaches the branches that the same GlobalTransactionManager instance began.
  • BranchTransaction.Status.SUCCESS rejects a branch that has already been ended, and requires that every Scanner object that you obtained from the branch has been closed. Otherwise, ending the branch throws IllegalStateException.
  • BranchTransaction.Status.FAILURE is lenient so that you can call it from a catch clause. Ending a branch that has already been ended does nothing, and an open Scanner object is accepted.
  • Close every Scanner object that you obtained from the branch before you end the branch. Do not close one after ending the branch. If you use a try-with-resources statement, end the branch after the block exits, since the Scanner object is not closed until then. A Scanner object that is left open is closed when the transaction is rolled back.
  • Executing a CRUD operation on a branch that has already been ended throws IllegalStateException, whichever outcome you declared.

Commit a global transaction​

After all the branches have ended, the process that began the global transaction commits it to make the writes of all the branches durable as a whole.

You can commit a global transaction as follows:

// Commit a global transaction.
globalTransaction.commit();
warning

Commit a global transaction only after every branch has ended with BranchTransaction.Status.SUCCESS. If a branch ended with BranchTransaction.Status.FAILURE, roll back the transaction instead.

ScalarDB does not detect a violation of this rule. No error is raised when you commit after a branch has ended with BranchTransaction.Status.FAILURE, and if you commit while a branch is still working, that branch's partial work may be committed: the commit proceeds with the writes that have been staged so far, and an operation that arrives late is rejected without stopping a commit that is already in flight.

Roll back or abort a global transaction​

If an error occurs when executing a global transaction, you can roll back or abort the transaction. Doing so discards the writes of all its branches. However, it does not end the branches. Each process that began a branch must still end it with BranchTransaction.Status.FAILURE.

You can roll back a global transaction as follows:

// Roll back a global transaction.
globalTransaction.rollback();

Or, you can abort a global transaction as follows:

// Abort a global transaction.
globalTransaction.abort();

For details about how to handle exceptions in ScalarDB, see How to handle exceptions.

Execute a global transaction across processes​

This subsection shows how the pieces fit together when a global transaction spans two processes.

The first process begins the global transaction, works on its own branch, passes the transaction ID to the second process, and then commits the transaction:

// In a method that declares throws TransactionException (or wider).
TransactionFactory transactionFactory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
GlobalTransactionManager transactionManager = transactionFactory.getGlobalTransactionManager();

// Begin a global transaction.
GlobalTransaction globalTransaction = transactionManager.begin();

BranchTransaction branch = null;
try {
// Execute CRUD operations in this process's own branch, and then end the branch.
branch = transactionManager.beginBranch(globalTransaction.getId());
Optional<Result> result = branch.get(...);
branch.insert(...);
branch.end(BranchTransaction.Status.SUCCESS);

// Pass the transaction ID to the second process, for example, in an RPC call, and wait until
// its branch has executed its CRUD operations and has been ended.
callSecondProcess(globalTransaction.getId());

// Commit the global transaction as a whole.
globalTransaction.commit();
} catch (UnknownTransactionStatusException e) {
// The status of the global transaction, whether it was committed or not, is unknown. Check
// whether it was committed before deciding how to proceed, and do not roll it back blindly.
} catch (Exception e) {
// End this process's own branch, declaring that its work failed.
if (branch != null) {
try {
branch.end(BranchTransaction.Status.FAILURE);
} catch (CrudException ex) {
// Ending the branch failed. The global transaction is rolled back below, so you don't need
// to do anything further. You can simply log the occurrence here.
}
}

// Roll back the global transaction, which discards the writes of all its branches.
try {
globalTransaction.rollback();
} catch (RollbackException ex) {
// Rolling back the global transaction failed. Since the transaction should eventually recover,
// you don't need to do anything further. You can simply log the occurrence here.
}
}

The second process begins a branch by using the transaction ID that it received, and executes only CRUD operations. Committing and rolling back stay with the first process:

// In a method that declares throws TransactionException (or wider).
TransactionFactory transactionFactory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
GlobalTransactionManager transactionManager = transactionFactory.getGlobalTransactionManager();

// Begin a branch of the global transaction by using the transaction ID that was received.
BranchTransaction branch = transactionManager.beginBranch(transactionId);

try {
// Execute CRUD operations in the branch, and then end the branch.
Optional<Result> result = branch.get(...);
branch.insert(...);
branch.end(BranchTransaction.Status.SUCCESS);
} catch (Exception e) {
// End the branch, declaring that its work failed.
try {
branch.end(BranchTransaction.Status.FAILURE);
} catch (CrudException ex) {
// Ending the branch failed. The process that began the global transaction rolls it back, so
// you don't need to do anything further. You can simply log the occurrence here.
}

throw e;
}

// Return to the caller, which commits or rolls back the global transaction.

If a CRUD operation in the branch fails, end the branch with BranchTransaction.Status.FAILURE and let the exception propagate to the process that began the global transaction so that it can roll the transaction back.

How to handle exceptions​

When executing a transaction, you will also need to handle exceptions properly.

warning

If you don't handle exceptions properly, you may face anomalies or data inconsistency.

Exceptions​

This section describes each exception that the APIs described in this guide can throw and what the exception indicates.

TransactionException and TransactionNotFoundException​

The begin(), beginReadOnly(), and beginBranch() APIs could throw TransactionException or TransactionNotFoundException:

  • If you catch TransactionException, this exception indicates that the transaction has failed to begin due to transient or non-transient faults. You can retry the transaction, but you may not be able to begin the transaction due to non-transient faults.
  • If you catch TransactionNotFoundException, this exception indicates that the transaction has failed to begin due to transient faults. In this case, you can retry the transaction.

The start(), startReadOnly(), and startBranch() methods are aliases of begin(), beginReadOnly(), and beginBranch(), respectively, so they throw the same exceptions as the methods that they are aliases of.

CrudException and CrudConflictException​

The APIs for CRUD operations (get(), scan(), put(), delete(), and mutate()) could throw CrudException or CrudConflictException:

  • If you catch CrudException, this exception indicates that the transaction CRUD operation has failed due to transient or non-transient faults. You can retry the transaction from the beginning, but the transaction may still fail if the cause is non-transient.
  • If you catch CrudConflictException, this exception indicates that the transaction CRUD operation has failed due to transient faults (for example, a conflict error). In this case, you can retry the transaction from the beginning.

The end(Status) API could also throw these exceptions. You can handle them in the same way that you handle the exceptions for the CRUD operations.

UnsatisfiedConditionException​

The APIs for mutation operations (put(), delete(), and mutate()) could also throw UnsatisfiedConditionException.

If you catch UnsatisfiedConditionException, this exception indicates that the condition for the mutation operation is not met. You can handle this exception according to your application requirements.

CommitException, CommitConflictException, and UnknownTransactionStatusException​

The commit() API could throw CommitException, CommitConflictException, or UnknownTransactionStatusException:

  • If you catch CommitException, this exception indicates that committing the transaction fails due to transient or non-transient faults. You can retry the transaction from the beginning, but the transaction may still fail if the cause is non-transient.
  • If you catch CommitConflictException, this exception indicates that committing the transaction has failed due to transient faults (for example, a conflict error). In this case, you can retry the transaction from the beginning.
  • If you catch UnknownTransactionStatusException, this exception indicates that the status of the transaction, whether it was successful or not, is unknown. In this case, you need to check if the transaction is committed successfully and retry the transaction if it has failed.

How to identify a transaction status is delegated to users. You may want to create a transaction status table and update it transactionally with other application data so that you can get the status of a transaction from the status table.

RollbackException and AbortException​

The rollback() API could throw RollbackException, and the abort() API, which is an alias of rollback(), could throw AbortException. Both exceptions indicate that rolling back the transaction failed due to transient or non-transient faults.

If you catch either exception, the transaction should eventually recover, so you don't need to do anything further. You can simply log the occurrence.

Authentication and authorization exceptions​

When authentication and authorization are enabled, an operation can fail because the current user isn't authenticated or doesn't have the required privilege. The Administrative API, Transaction API, and Microservice Transaction API report such failures through the same four methods:

  • isAuthenticationError() returns true if the current user isn't authenticated.
  • isAuthorizationError() returns true if the current user doesn't have the required privilege.
  • isSuperuserRequired() returns true if the operation requires superuser privileges.
  • getRequiredPrivilege() returns the privilege that the operation requires, if the failure was caused by a missing privilege.

For the Administrative API, these methods are on ExecutionException. For the Transaction API and the Microservice Transaction API, they are on TransactionException and its subclasses. For example, if the current user doesn't have the READ privilege on a table, get() throws CrudException for which isAuthorizationError() returns true.

For details about authentication and authorization status codes, see Authentication and Authorization Error Codes.

Examples​

In the following examples, for UnknownTransactionStatusException, the transaction is not retried because the application must check if the transaction was successful to avoid potential duplicate operations. For other exceptions, the transaction is retried because the cause of the exception is transient or non-transient. If the cause of the exception is transient, the transaction may succeed if you retry it. However, if the cause of the exception is non-transient, the transaction will still fail even if you retry it. In such a case, you will exhaust the number of retries.

note

In the following examples, the transaction is attempted three times maximum and sleeps for 100 milliseconds before each retry. But you can choose a retry policy, such as exponential backoff, according to your application requirements.

Transaction API example​

The following sample code shows how to handle exceptions:

public class Sample {
public static void main(String[] args) throws Exception {
TransactionFactory factory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
DistributedTransactionManager transactionManager = factory.getTransactionManager();

int retryCount = 0;
TransactionException lastException = null;

while (true) {
if (retryCount++ > 0) {
// Attempt the transaction three times maximum.
if (retryCount > 3) {
// Throw the last exception if the number of retries exceeds the maximum.
throw lastException;
}

// Sleep 100 milliseconds before retrying the transaction.
TimeUnit.MILLISECONDS.sleep(100);
}

DistributedTransaction transaction = null;
try {
// Begin a transaction.
transaction = transactionManager.begin();

// Execute CRUD operations in the transaction.
Optional<Result> result = transaction.get(...);
List<Result> results = transaction.scan(...);
transaction.insert(...);
transaction.delete(...);

// Commit the transaction.
transaction.commit();
} catch (UnsatisfiedConditionException e) {
// You need to handle `UnsatisfiedConditionException` only if a mutation operation specifies a condition.
// This exception indicates the condition for the mutation operation is not met.

try {
transaction.rollback();
} catch (RollbackException ex) {
// Rolling back the transaction failed. Since the transaction should eventually recover,
// you don't need to do anything further. You can simply log the occurrence here.
}

// You can handle the exception here, according to your application requirements.

return;
} catch (UnknownTransactionStatusException e) {
// If you catch `UnknownTransactionStatusException` when committing the transaction,
// it indicates that the status of the transaction, whether it was successful or not, is unknown.
// In such a case, you need to check if the transaction is committed successfully or not and
// retry the transaction if it failed. How to identify a transaction status is delegated to users.
return;
} catch (TransactionException e) {
// For other exceptions, you can retry the transaction.

// For `CrudConflictException`, `CommitConflictException`, and `TransactionNotFoundException`,
// you can basically retry the transaction. However, for the other exceptions, the transaction
// will still fail if the cause of the exception is non-transient. In such a case, you will
// exhaust the number of retries and throw the last exception.

if (transaction != null) {
try {
transaction.rollback();
} catch (RollbackException ex) {
// Rolling back the transaction failed. The transaction should eventually recover,
// so you don't need to do anything further. You can simply log the occurrence here.
}
}

lastException = e;
}
}
}
}

Microservice Transaction API example​

The following sample code shows how to handle exceptions when executing a global transaction. Because the outcome of the transaction is driven by the GlobalTransaction handle, the transaction is rolled back through that handle and not through a branch:

public class Sample {
public static void main(String[] args) throws Exception {
TransactionFactory factory = TransactionFactory.create("<CONFIGURATION_FILE_PATH>");
GlobalTransactionManager transactionManager = factory.getGlobalTransactionManager();

int retryCount = 0;
TransactionException lastException = null;

while (true) {
if (retryCount++ > 0) {
// Attempt the transaction three times maximum.
if (retryCount > 3) {
// Throw the last exception if the number of retries exceeds the maximum.
throw lastException;
}

// Sleep 100 milliseconds before retrying the transaction.
TimeUnit.MILLISECONDS.sleep(100);
}

GlobalTransaction globalTransaction = null;
BranchTransaction branch = null;
try {
// Begin a global transaction.
globalTransaction = transactionManager.begin();

// Execute CRUD operations in this process's own branch, and then end the branch.
branch = transactionManager.beginBranch(globalTransaction.getId());
Optional<Result> result = branch.get(...);
branch.insert(...);
branch.delete(...);
branch.end(BranchTransaction.Status.SUCCESS);

// Let the other processes execute CRUD operations in their own branches and end them.
callOtherProcesses(globalTransaction.getId());

// Commit the global transaction.
globalTransaction.commit();
} catch (UnsatisfiedConditionException e) {
// You need to handle `UnsatisfiedConditionException` only if a mutation operation specifies a condition.
// This exception indicates the condition for the mutation operation is not met.

// End this process's own branch, declaring that its work failed.
if (branch != null) {
try {
branch.end(BranchTransaction.Status.FAILURE);
} catch (CrudException ex) {
// Ending the branch failed. The global transaction is rolled back below, so you don't
// need to do anything further. You can simply log the occurrence here.
}
}

try {
globalTransaction.rollback();
} catch (RollbackException ex) {
// Rolling back the global transaction failed. Since the transaction should eventually recover,
// you don't need to do anything further. You can simply log the occurrence here.
}

// You can handle the exception here, according to your application requirements.

return;
} catch (UnknownTransactionStatusException e) {
// If you catch `UnknownTransactionStatusException` when committing the global transaction,
// it indicates that the status of the transaction, whether it was successful or not, is unknown.
// In such a case, you need to check if the transaction is committed successfully or not and
// retry the transaction if it failed. How to identify a transaction status is delegated to users.
return;
} catch (TransactionException e) {
// For other exceptions, you can retry the global transaction.

// For `CrudConflictException`, `CommitConflictException`, and `TransactionNotFoundException`,
// you can basically retry the transaction. However, for the other exceptions, the transaction
// will still fail if the cause of the exception is non-transient. In such a case, you will
// exhaust the number of retries.

// End this process's own branch, declaring that its work failed.
if (branch != null) {
try {
branch.end(BranchTransaction.Status.FAILURE);
} catch (CrudException ex) {
// Ending the branch failed. The global transaction is rolled back below, so you don't
// need to do anything further. You can simply log the occurrence here.
}
}

if (globalTransaction != null) {
try {
globalTransaction.rollback();
} catch (RollbackException ex) {
// Rolling back the global transaction failed. The transaction should eventually recover,
// so you don't need to do anything further. You can simply log the occurrence here.
}
}

lastException = e;
} catch (Exception e) {
// The call to the other processes can fail with an exception that ScalarDB does not define,
// such as an RPC failure. Retrying does not necessarily help in that case, so this sample
// code cleans up and propagates the exception instead.

// End this process's own branch, declaring that its work failed.
if (branch != null) {
try {
branch.end(BranchTransaction.Status.FAILURE);
} catch (CrudException ex) {
// Ending the branch failed. The global transaction is rolled back below, so you don't
// need to do anything further. You can simply log the occurrence here.
}
}

if (globalTransaction != null) {
try {
globalTransaction.rollback();
} catch (RollbackException ex) {
// Rolling back the global transaction failed. The transaction should eventually recover,
// so you don't need to do anything further. You can simply log the occurrence here.
}
}

throw e;
}
}
}
}
note

Retrying a global transaction means retrying it from the beginning in every process that takes part in it, since rolling back the transaction discards the writes of all its branches.