Oracle Lock-Free Reservation with Micronaut Data JDBC

Learn how to map Oracle reservable columns and update them concurrently with Micronaut Data JDBC reservation methods.

1. Getting Started

In this guide, we will create a Python application built with Pyronaut.

In this guide, you will build a small account service with Micronaut Data JDBC and Oracle Database lock-free reservations. An account has a balance, and many deposits and withdrawals can run concurrently against the same account. You will map the balance to an Oracle RESERVABLE column with a CHECK constraint, and update it with derived reservation methods.

With lock-free reservation, Oracle does not lock the account row for each update. It journals each reservation, checks it against the column constraints, and applies it when the transaction commits. Concurrent transactions can therefore reserve amounts from the same row without waiting for each other, as long as the constraints hold.

Lock-free reservation requires Oracle Database 23.26.1 or later. Oracle documentation may refer to this release by its 26ai name. Micronaut Data does not detect the database version, so use @Reservable only with a compatible database; otherwise Oracle rejects the generated DDL and DML.

2. What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • GraalPy installed and the Pyronaut CLI available locally

  • Docker installed to run Oracle Database with Micronaut Test Resources.

3. Solution

We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.

4. Writing the Application

Create an application using the Pyronaut CLI (Command Line Interface) or Pyronaut Launch

pyronaut create example.micronaut.micronautguide --features=data-jdbc,oracle,serialization-jackson,validation

The oracle feature adds the Oracle JDBC driver, com.oracle.database.jdbc:ojdbc11, to the runtime dependencies in pyproject.toml.

4.1. Database Configuration

Configure the datasource:

config/application.toml
[datasources.default]
schema-generate = "CREATE_DROP"
(1)
driver-class-name = "oracle.jdbc.OracleDriver"
(2)
db-type = "oracle"
(3)
dialect = "ORACLE"
1 Use the Oracle driver.
2 Set the database type so that Micronaut Test Resources detects the database and starts an Oracle container.
3 Configure the Oracle dialect.

Micronaut Test Resources starts Oracle Database Free, which supports lock-free reservation, for tests and development:

config/application.toml
(1)
[test-resources.containers.oracle]
image-name = "gvenzl/oracle-free"
image-tag = "latest"
startup-timeout = "600s"
1 Use the latest full Oracle Database Free image and allow the container enough time to start.

5. Account

Create an Account entity:

src/example/micronaut/domain/account.py
from dataclasses import dataclass
from typing import Annotated

from jakarta.validation.constraints import NotBlank, PositiveOrZero
from micronaut.data.annotation import GeneratedValue, Id, MappedEntity, Reservable
from micronaut.serde.annotation import Serdeable


@Serdeable  (1)
@MappedEntity("ACCOUNT")  (2)
@dataclass
class Account:
    id: Annotated[int | None, Id, GeneratedValue]  (3)
    name: Annotated[str, NotBlank]  (4)
    balance: Annotated[int, Reservable, PositiveOrZero]  (5)
1 Declare @Serdeable so the account can be serialized in HTTP requests and responses.
2 Map the entity to the ACCOUNT table.
3 The ID is generated by the database.
4 name is an ordinary column. Generated save and update operations can change it.
5 @Reservable generates a RESERVABLE column. @PositiveOrZero generates a column CHECK constraint, so Oracle rejects any reservation that would make the balance negative. A table can have up to 10 reservable columns.

Schema generation creates the table with the reservable column and its check constraint:

CREATE TABLE "ACCOUNT" (
    "ID" NUMBER(19) NOT NULL PRIMARY KEY,
    "NAME" VARCHAR(255) NOT NULL,
    "BALANCE" NUMBER(19) RESERVABLE CONSTRAINT "CK_ACCOUNT_BALANCE_GE_0" CHECK ("BALANCE" >= 0) NOT NULL
)

Other supported validation annotations are @Positive, @Negative, @NegativeOrZero, @Min, @Max, @DecimalMin, and @DecimalMax. Validation annotations generate constraints only on @Reservable properties.

6. Repository

Create an Oracle JDBC repository:

src/example/micronaut/account_repository.py
from typing import Annotated

from micronaut.data.annotation import Id
from micronaut.data.jdbc.annotation import JdbcRepository
from micronaut.data.model.query.builder.sql import Dialect
from micronaut.data.repository import CrudRepository

from .domain.account import Account


@JdbcRepository(dialect=Dialect.ORACLE)  (1)
class AccountRepository(CrudRepository[Account, int]):  (2)

    def reserveIncrementBalance(self, id: Annotated[int, Id], balance: int) -> int: ...  (3)

    def reserveDecrementBalance(self, id: Annotated[int, Id], balance: int) -> int: ...  (4)
1 Micronaut Data generates SQL for the Oracle dialect at compilation time.
2 Account has an ordinary updatable property, name, so the repository can extend CrudRepository.
3 A derived reservation method starts with reserve, followed by Increment<Property> or Decrement<Property> operations. The @Id parameter identifies the row, and the delta parameter has the name of its reservable property. A deposit increments the balance. The long return value is the number of updated rows, not the new balance: Oracle applies the reservation when the transaction commits, so the new value is not available to the statement. Reservation methods can also return void.
4 A withdrawal decrements the balance. To update several reservable properties in one statement, join operations with And, for example, reserveIncrementBalanceAndDecrementCredit for an entity with two reservable properties, balance and credit.

The reservation methods generate updates that change the column by a delta, instead of assigning a new value:

UPDATE "ACCOUNT" SET "BALANCE"=("BALANCE" + ?) WHERE ("ID" = ?)
UPDATE "ACCOUNT" SET "BALANCE"=("BALANCE" - ?) WHERE ("ID" = ?)

Oracle does not allow direct assignments to reservable columns, or a mix of reservable and ordinary columns in one update. Micronaut Data therefore leaves balance out of the generated save and update statements, which only change name.

If all updatable properties of an entity are reservable, Micronaut Data cannot generate save or update operations and reports an error at compilation time. Extend GenericRepository instead and declare insert, finder, delete, and reservation methods explicitly.

7. Controller

Expose account creation, deposits, and withdrawals:

src/example/micronaut/account_controller.py
from typing import Annotated

from micronaut.http import HttpResponse, HttpStatus
from micronaut.http.annotation import Body, Controller, Get, Post, QueryValue, Status
from micronaut.scheduling import TaskExecutors
from micronaut.scheduling.annotation import ExecuteOn

from .account_repository import AccountRepository
from .domain.account import Account


@ExecuteOn(TaskExecutors.BLOCKING)  (1)
@Controller("/accounts")  (2)
class AccountController:
    def __init__(self, repository: AccountRepository):  (3)
        self.repository = repository

    @Post
    @Status(HttpStatus.CREATED)
    def create(self, account: Annotated[Account, Body]) -> Account:
        return self.repository.save(account)

    @Get
    def find_all(self) -> list[Account]:
        return self.repository.findAll()

    @Post("/{id}/deposit")
    def deposit(self, id: int, amount: Annotated[int, QueryValue]) -> HttpResponse[Account]:
        if self.repository.reserveIncrementBalance(id, amount) == 0:  (4)
            return HttpResponse.notFound()
        return HttpResponse.ok(self.repository.findById(id).orElseThrow())  (5)

    @Post("/{id}/withdraw")
    def withdraw(self, id: int, amount: Annotated[int, QueryValue]) -> HttpResponse[Account]:
        if self.repository.reserveDecrementBalance(id, amount) == 0:
            return HttpResponse.notFound()
        return HttpResponse.ok(self.repository.findById(id).orElseThrow())
1 Repository calls block while they wait for the database, so run them on the blocking executor instead of the event loop.
2 The class is defined as a controller mapped to the path /accounts.
3 Use constructor injection to inject the AccountRepository.
4 The reservation method returns the number of updated rows. Respond with 404 Not Found when there is no account with the given ID.
5 The reservation method runs in its own transaction, which has committed by now, so reading the account returns the new balance. Inside the transaction that made a reservation, a query still returns the previous value.

8. Handle a Constraint Violation

When a reservation would violate a column constraint, Oracle rejects it, and Micronaut Data throws DataIntegrityViolationException. Without a handler, this results in a 500 Internal Server Error response. Report it as a conflict instead:

src/example/micronaut/reservation_exception_handler.py
from jakarta.inject import Singleton
from micronaut.data.exceptions import DataIntegrityViolationException
from micronaut.http import HttpRequest, HttpResponse, HttpStatus
from micronaut.http.annotation import Produces
from micronaut.http.server.exceptions import ExceptionHandler
from micronaut.http.server.exceptions.response import ErrorContext, ErrorResponseProcessor


@Produces
@Singleton
class ReservationExceptionHandler(
        ExceptionHandler[DataIntegrityViolationException, HttpResponse]):  (1)

    def __init__(self, error_response_processor: ErrorResponseProcessor):
        self.error_response_processor = error_response_processor

    def handle(self, request: HttpRequest, exception: DataIntegrityViolationException) -> HttpResponse:
        error_context = (ErrorContext.builder(request)
                         .cause(exception)
                         .errorMessage("The operation violates an account constraint")  (2)
                         .build())
        return self.error_response_processor.processResponse(
            error_context, HttpResponse.status(HttpStatus.CONFLICT))  (3)
1 Handle DataIntegrityViolationException, which Micronaut Data throws for integrity constraint failures with JDBC and R2DBC.
2 Tell the client why the operation failed.
3 Respond with 409 Conflict.

DataIntegrityViolationException is a database error. It is different from a Bean Validation ConstraintViolationException, which Micronaut throws before any SQL runs when validation of a method argument fails.

9. Test

Add tests for deposits, withdrawals, and constraint failures, through the repository and over HTTP:

tests/example/micronaut/test_account.py
import pytest
import requests
from micronaut.data.exceptions import DataIntegrityViolationException
from pyronaut.test import MicronautTest, micronaut_test_fixture

from example.micronaut.account_repository import AccountRepository
from example.micronaut.domain.account import Account


@pytest.fixture
def my_context(request):
    fixture = micronaut_test_fixture(
        request,
        MicronautTest(transactional=False),  (1)
    )
    yield fixture
    fixture.stop()


@pytest.fixture
def repository(my_context):
    return my_context[AccountRepository]


@pytest.fixture
def client(my_context):
    return requests.with_context(my_context)


def test_deposit_increments_balance(repository):
    account = repository.save(Account(None, "Checking", 100))

    updated = repository.reserveIncrementBalance(account.id, 25)  (2)

    assert updated == 1
    found = repository.findById(account.id).orElseThrow()
    assert found.balance == 125


def test_withdrawal_beyond_balance_is_rejected(repository):
    account = repository.save(Account(None, "Checking", 100))

    with pytest.raises(BaseException) as error:
        repository.reserveDecrementBalance(account.id, 1000)  (3)
    assert isinstance(error.value, DataIntegrityViolationException)

    found = repository.findById(account.id).orElseThrow()
    assert found.balance == 100  (4)


def test_withdrawal_beyond_balance_responds_with_conflict(repository, client):
    account = repository.save(Account(None, "Checking", 100))

    response = client.post(f"/accounts/{account.id}/withdraw?amount=1000")

    assert response.status_code == 409  (5)
    assert "The operation violates an account constraint" in response.text  (6)


def test_account_is_created_deposited_and_withdrawn_over_http(client):
    created = client.post("/accounts", json={"name": "Savings", "balance": 100})  (7)
    assert created.status_code == 201
    account = created.json()

    deposited = client.post(f"/accounts/{account['id']}/deposit?amount=25").json()  (8)
    assert deposited["balance"] == 125

    withdrawn = client.post(f"/accounts/{account['id']}/withdraw?amount=50").json()
    assert withdrawn["balance"] == 75


def test_deposit_for_unknown_account_responds_with_not_found(client):
    response = client.post("/accounts/-1/deposit?amount=1")

    assert response.status_code == 404  (9)
1 Disable the test transaction. Oracle applies reservations when the transaction commits, so each repository call must commit its own transaction for the test to read the result.
2 Deposit 25: the reservation increments balance by the given delta and returns the number of updated rows.
3 A withdrawal larger than the balance would make balance negative, so it violates the generated CHECK constraint.
4 The failed reservation leaves balance unchanged.
5 Over HTTP, the exception handler responds with 409 Conflict.
6 The response body contains the message from the exception handler.
7 Create an account over HTTP. The controller responds with 201 Created.
8 Deposit and withdraw over HTTP. Each response contains the committed balance.
9 A reservation for an unknown account updates no rows, so the controller responds with 404 Not Found.

10. Testing the Application

To run the tests:

pyronaut install
pyronaut validate-config
pyronaut test

When you run the tests, Micronaut Test Resources starts an Oracle Database Free container before the application connects.

11. Inspect the Generated Schema

To inspect the generated columns and constraints, connect to the database, for example, to the Test Resources container while the application runs. The RESERVABLE_COLUMN column of USER_TAB_COLS shows which columns are reservable:

SELECT column_name, data_type, reservable_column
FROM user_tab_cols
WHERE table_name = 'ACCOUNT'
ORDER BY column_id;

List the generated check constraints:

SELECT constraint_name, search_condition
FROM user_constraints
WHERE table_name = 'ACCOUNT'
  AND constraint_type = 'C'
  AND constraint_name LIKE 'CK_%';

12. Run the Application

Start the application with pyronaut run, and create an account:

curl -X POST http://localhost:8080/accounts \
     -H 'Content-Type: application/json' \
     -d '{"name":"Checking","balance":100}'

Deposit 25:

curl -X POST 'http://localhost:8080/accounts/1/deposit?amount=25'

The response shows a balance of 125.

Withdraw 50:

curl -X POST 'http://localhost:8080/accounts/1/withdraw?amount=50'

The response shows a balance of 75.

Attempt to withdraw more than the balance:

curl -i -X POST 'http://localhost:8080/accounts/1/withdraw?amount=1000'

The response is 409 Conflict, and the account keeps its previous balance.

12.1. Micronaut Test Resources Goals

  • zero-configuration: without adding any configuration, test resources should be spawned and the application configured to use them. Configuration is only required for advanced use cases.

  • classpath isolation: use of test resources shouldn’t leak into your application classpath, nor your test classpath

  • compatible with GraalVM native: if you build a native binary, or run tests in native mode, test resources should be available

  • easy to use: the Micronaut build plugins for Gradle and Maven should handle the complexity of figuring out the dependencies for you

  • extensible: you can implement your own test resources, in case the built-in ones do not cover your use case

  • technology agnostic: while lots of test resources use Testcontainers under the hood, you can use any other technology to create resources

13. Next Steps

Read more about Oracle Reservable Columns in Micronaut Data and Oracle lock-free reservation.

14. License

All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images).