Question 14 Β· Section 11

What does Propagation.NESTED do

Spring implements NESTED through JDBC Savepoints (not through new transactions):

Language versions: English Russian Ukrainian

🟒 Junior Level

Propagation.NESTED is a transaction propagation type in Spring that creates a β€œnested” transaction inside an already existing one. If the outer transaction rolls back, the nested one also rolls back. But if the nested one rolls back β€” the outer one can continue working.

Simple analogy: You’re writing a document (outer transaction). Periodically you β€œsave a version” (savepoint). If a new edit ruins the document, you can roll back to the last version (savepoint) without losing all your previous work. But if you decide to trash the entire document β€” all versions disappear too.

SQL example (JDBC Savepoint):

@Service
public class BatchImportService {

    @Autowired private NestedImportService nestedImportService;

    @Transactional
    public void importAll(List<Data> dataList) {
        for (Data data : dataList) {
            try {
                // Each record β€” a nested "mini-transaction"
                nestedImportService.importOne(data);
            } catch (Exception e) {
                // Only this record rolls back, others continue
                log.warn("Failed to import: {}", data.getId());
            }
        }
        // At the end β€” one common COMMIT for all successful records
    }
}

@Service
public class NestedImportService {

    @Transactional(propagation = Propagation.NESTED)
    public void importOne(Data data) {
        repo.save(data);
        // If exception β†’ rollback to savepoint (not the entire batch)
    }
}

Key difference from REQUIRES_NEW:

  • NESTED: One COMMIT at the very end, nested is a savepoint within the same transaction
  • REQUIRES_NEW: Two independent COMMITs, inner transaction commits immediately

When to use:

  • Batch data processing with acceptable partial failures
  • When you need to β€œsave” part of the work, but the final commit must be atomic

🟑 Middle Level

How it works internally

Spring implements NESTED through JDBC Savepoints (not through new transactions):

Call: outerMethod() β†’ innerMethod(NESTED)

1. Spring checks: is there an active transaction? β†’ Yes
2. Spring creates JDBC Savepoint:

   Connection conn = dataSource.getConnection();
   Savepoint sp = conn.setSavepoint("NESTED_SAVEPOINT_1");

3. innerMethod() executes
4. If success β†’ savepoint released (but NOT commit!)
5. If exception β†’ conn.rollback(sp) β†’ rollback to savepoint
6. At the end of outerMethod() β†’ one common conn.commit()

Important: NESTED works only with DataSourceTransactionManager and JpaTransactionManager. It does NOT work with JTA in savepoint mode. JTA (Java Transaction API) β€” API for distributed transactions spanning multiple DBs or systems. Spring automatically falls back to REQUIRES_NEW behavior. So for JTA, it’s better to specify REQUIRES_NEW explicitly.

Practical application

Scenario 1: Batch import with partial failure tolerance

@Service
public class CsvImportService {

    @Autowired private RowImportService rowImportService;

    @Transactional
    public ImportResult importCsv(MultipartFile file) {
        int success = 0;
        int failed = 0;

        List<String> rows = parseCsv(file);
        for (String row : rows) {
            try {
                rowImportService.importRow(row);  // NESTED
                success++;
            } catch (ValidationException e) {
                failed++;
                // This row rolled back to savepoint, continue
            }
        }
        return new ImportResult(success, failed);
        // COMMIT β€” all successful rows saved
    }
}

@Service
public class RowImportService {

    @Transactional(propagation = Propagation.NESTED)
    public void importRow(String row) {
        Data data = validateAndParse(row);  // may throw ValidationException
        repo.save(data);
    }
}

Scenario 2: Orchestration with compensation

@Service
public class OrderOrchestrator {

    @Autowired private InventoryService inventoryService;
    @Autowired private PaymentService paymentService;
    @Autowired private NotificationService notificationService;

    @Transactional
    public OrderResult createOrder(Order order) {
        try {
            inventoryService.reserve(order.getItems());     // NESTED
        } catch (Exception e) {
            return OrderResult.failed("No stock");
        }

        try {
            paymentService.charge(order.getTotal());        // NESTED
        } catch (Exception e) {
            // Payment failed β€” reservation also rolls back (common transaction)
            throw new OrderException("Payment failed", e);
        }

        // All successful β€” notification (REQUIRED, part of common transaction)
        notificationService.sendOrderConfirmation(order);

        return OrderResult.success();
        // COMMIT β€” all changes atomic
    }
}

Typical mistakes

Mistake Consequence Solution
NESTED without active outer transaction Behaves like REQUIRED (creates new) Ensure outer method is @Transactional
NESTED + Hibernate L1 cache inconsistency Stale entities after savepoint rollback em.flush() + em.clear() after rollback
NESTED with JTA Not supported β€” fallback to REQUIRES_NEW Use REQUIRES_NEW explicitly for JTA
Assuming NESTED = REQUIRES_NEW Inner changes NOT visible to others until common commit Understand: NESTED = savepoint, REQUIRES_NEW = new tx
Long transaction with many savepoints Undo log accumulation, memory pressure Limit number of nested calls

Comparison: NESTED vs REQUIRES_NEW

Characteristic NESTED REQUIRES_NEW
Physical transactions 1 2
Mechanism JDBC Savepoint Suspend + new transaction
Inner commit No (only release savepoint) Yes, immediate
Inner rollback To savepoint Full inner tx
Data visibility to other tx Only after common commit Immediately after inner commit
Outer tx rollback Rolls back inner Doesn’t affect inner
JTA support No Yes
Hibernate L1 cache Issues on rollback No issues
Connection usage 1 connection 2 connections (suspend + new)
Overhead Low (~1-3ms) Medium (~5-15ms)

When NOT to use NESTED

  • JTA / distributed transactions β€” not supported
  • When inner transaction must commit independently β€” use REQUIRES_NEW
  • With Hibernate without flush/clear β€” risk of L1 cache inconsistency
  • For auditing β€” audit usually needed independently of outer tx, better use REQUIRES_NEW

When NESTED vs REQUIRES_NEW

  • Audit/logging = REQUIRES_NEW (data must survive outer rollback)
  • Batch processing with partial failure = NESTED (single commit, one connection)
  • Distributed transaction = REQUIRES_NEW (NESTED doesn’t support JTA)
  • Need to see inner data from other tx = REQUIRES_NEW (NESTED visible only after common commit)

πŸ”΄ Senior Level

Internal Implementation: Savepoint Mechanics and Spring Integration

JdbcTransactionObjectSupport β€” Savepoint Management

// org.springframework.jdbc.datasource.JdbcTransactionObjectSupport
// Spring Framework 6.x

public class JdbcTransactionObjectSupport implements SavepointManager, SmartTransactionObject {

    private ConnectionHolder connectionHolder;
    private Savepoint currentSavepoint;
    private Integer previousIsolationLevel;
    private boolean rollbackOnly = false;
    private boolean savepointAllowed = false;

    @Override
    public Object createSavepoint(String name) throws TransactionException {
        try {
            if (!this.savepointAllowed) {
                throw new NestedTransactionNotSupportedException(
                    "Cannot create savepoint β€” no active transaction or not supported");
            }
            // JDBC 3.0 API
            this.currentSavepoint = getConnection().setSavepoint(name);
            return this.currentSavepoint;
        } catch (SQLException ex) {
            throw new CannotCreateSavepointException("Could not create JDBC savepoint", ex);
        }
    }

    @Override
    public void rollbackToSavepoint(Object savepoint) throws TransactionException {
        try {
            Savepoint sp = (Savepoint) savepoint;
            // JDBC rollback to savepoint
            getConnection().rollback(sp);
            // Important: connection remains active, transaction continues
            this.currentSavepoint = null;
        } catch (SQLException ex) {
            throw new TransactionSystemException("Could not roll back to JDBC savepoint", ex);
        }
    }

    @Override
    public void releaseSavepoint(Object savepoint) throws TransactionException {
        try {
            Savepoint sp = (Savepoint) savepoint;
            getConnection().releaseSavepoint(sp);
            // Non-fatal if DB auto-releases on commit (MySQL)
        } catch (SQLException ex) {
            // Log but don't fail β€” some databases release savepoints automatically
        }
    }
}

AbstractPlatformTransactionManager β€” NESTED Handling

// Key fragment from handleExistingTransaction()

case PROPAGATION_NESTED:
    if (!isNestedTransactionAllowed()) {
        throw new NestedTransactionNotSupportedException(
            "Transaction manager does not allow nested transactions");
    }

    if (useSavepointForNestedTransaction()) {
        // DataSourceTransactionManager, JpaTransactionManager β†’ savepoint
        DefaultTransactionStatus status =
            prepareTransactionStatus(definition, transaction, false, false, debugEnabled, null);

        // Create savepoint
        status.createAndHoldSavepoint();

        return status;
    } else {
        // JTA β†’ no savepoint support β†’ fallback to REQUIRES_NEW
        SuspendedResourcesHolder suspendedResources = suspend(transaction);
        // ... creates a new transaction
    }

useSavepointForNestedTransaction() Decision

// DataSourceTransactionManager β†’ true (supports savepoints)
// JpaTransactionManager β†’ true (delegates to JDBC)
// JtaTransactionManager β†’ false (JTA doesn't support savepoints)
// HibernateTransactionManager β†’ true (through JDBC connection)

protected boolean useSavepointForNestedTransaction() {
    return (getDataSource() != null);  // Simplified
}

Savepoint Implementation in Various DBs

PostgreSQL:

BEGIN;
  SAVEPOINT sp1;
  INSERT INTO orders (id, amount) VALUES (1, 100);

  -- Rollback to savepoint
  ROLLBACK TO SAVEPOINT sp1;
  -- orders empty β€” insert undone, but transaction active

  INSERT INTO orders (id, amount) VALUES (2, 200);
COMMIT;
-- orders: (2, 200)

-- PostgreSQL savepoints:
-- - Stored in memory (not on disk)
-- - Can be nested (savepoint within savepoint)
-- - Auto-released on commit

MySQL/InnoDB:

START TRANSACTION;
  SAVEPOINT sp1;
  INSERT INTO orders VALUES (1, 100);

  ROLLBACK TO SAVEPOINT sp1;
  -- In MySQL: auto-release savepoint after rollback!
  -- Repeated ROLLBACK TO SAVEPOINT sp1 β†’ ERROR

  INSERT INTO orders VALUES (2, 200);
COMMIT;

-- MySQL savepoints:
-- - Auto-released after ROLLBACK TO (cannot reuse)
-- - Not logged in binlog (statement-based replication safe)
-- - This behavior is stable across all supported versions MySQL 5.7+ and PostgreSQL 9+.
--   Spring generates unique savepoint names starting from version 3.x.

Oracle:

-- Oracle savepoints:
-- - Can reuse name (unlike MySQL)
-- - Stored in PGA (memory)
-- - Support nested savepoints

Architectural Trade-offs

Approach A: NESTED (savepoints)

  • βœ… Pros: One connection, single commit, partial rollback, lower overhead than REQUIRES_NEW
  • ❌ Cons: Doesn’t work with JTA, Hibernate L1 cache issues, savepoint limitations (MySQL auto-release), no independent commit
  • Suitable for: batch processing, bulk operations, compensation patterns within a single DB

Approach B: REQUIRES_NEW

  • βœ… Pros: Full isolation, works with JTA, independent commit, clean Hibernate semantics
  • ❌ Cons: Two connections, suspend/resume overhead, inconsistency risk (inner committed, outer rolled back)
  • Suitable for: audit, notification, cross-database operations

Approach C: Manual savepoint management

@Transactional
public void manualSavepoint() {
    Connection conn = DataSourceUtils.getConnection(dataSource);
    Savepoint sp = conn.setSavepoint("my_sp");

    try {
        // business logic
    } catch (Exception e) {
        conn.rollback(sp);  // Manual rollback
    }
    // ...
}
  • βœ… Pros: Full control, can integrate with any logic
  • ❌ Cons: Boilerplate, error-prone, bypasses Spring transaction management
  • Suitable for: complex scenarios where Spring propagation is insufficient

Edge Cases and Corner Cases

1. MySQL Savepoint Auto-Release:

@Transactional
public void mysqlSavepointIssue() {
    nestedService.doWork();  // NESTED β†’ SAVEPOINT sp1
    // If rollback to sp1 inside doWork() β†’ MySQL auto-releases sp1

    nestedService.doWork2();  // NESTED β†’ tries to create SAVEPOINT sp1 again
    // PostgreSQL: OK (savepoint can be reused)
    // MySQL: OK (new name sp2)

    // But if Spring reuses the same name β†’ MySQL ERROR:
    // "Savepoint 'NESTED_SAVEPOINT_1' does not exist"
}

Spring solves this by generating unique names:

// DefaultTransactionStatus
private String generateSavepointName() {
    return "NESTED_SAVEPOINT_" + System.identityHashCode(this);
}

2. Hibernate L1 Cache and Savepoint Rollback:

@Transactional
public void hibernateSavepointIssue() {
    Entity e1 = new Entity("first");
    em.persist(e1);
    em.flush();  // INSERT into DB

    try {
        nestedService.failOperation();  // NESTED β†’ throws exception
    } catch (Exception ex) {
        // Savepoint rollback: DB rolled back INSERT
        // BUT: Hibernate L1 cache (PersistenceContext) still contains e1!
        // e1.status = MANAGED, but no row in DB
    }

    // On flush/commit:
    // Hibernate may try UPDATE e1 β†’ ERROR (row doesn't exist)
    // Or: Hibernate dirty checking β†’ INSERT e1 again β†’ duplicate
}

Solution:

@Transactional(propagation = Propagation.NESTED)
public void failOperation() {
    Entity e = new Entity("will-fail");
    em.persist(e);
    em.flush();
    // After flush, if exception β†’ savepoint rollback removes INSERT from DB
    // But L1 cache still contains e β†’ need em.clear()
    throw new RuntimeException("fail");
}

// Outer method:
@Transactional
public void safeNestedOperation() {
    try {
        nestedService.failOperation();
    } catch (Exception e) {
        em.clear();  // Clear L1 cache from potentially rolled-back entities
    }
}

Step by step: (1) em.persist() registers entity in PersistenceContext as MANAGED.
(2) em.flush() sends INSERT to DB. (3) Exception triggers savepoint rollback β€”
INSERT removed from DB. (4) But PersistenceContext still contains entity with
MANAGED status. (5) On final commit, Hibernate tries to flush again β†’ error.
em.clear() solves the problem by discarding the entire PersistenceContext.

3. Nested Savepoints (savepoint within savepoint):

@Transactional
public void deeplyNested() {
    level1Service.doWork();  // NESTED β†’ SAVEPOINT sp1

    try {
        level2Service.doWork();  // NESTED β†’ SAVEPOINT sp2 (inside sp1)
    } catch (Exception e) {
        // Rollback to sp2 β€” sp1 still active
    }

    try {
        level3Service.doWork();  // NESTED β†’ SAVEPOINT sp3
    } catch (Exception e) {
        // Rollback to sp3 β€” sp1 and sp2 (if not released) active
    }
}

// Limit: PostgreSQL β€” unlimited nested savepoints
// MySQL β€” unlimited, but each rollback auto-releases
// Oracle β€” unlimited

4. Savepoint and Sequence (PostgreSQL):

BEGIN;
  SAVEPOINT sp1;
  SELECT nextval('my_seq');  -- returns 1
  ROLLBACK TO SAVEPOINT sp1;

  -- Sequence value NOT rolled back!
  SELECT nextval('my_seq');  -- returns 2, not 1

  -- This is because sequence is a non-transactional structure

Impact: If business logic relies on sequence values inside nested transactions, there will be gaps.

5. Savepoint and Temporary Tables:

-- PostgreSQL: temporary tables do NOT rollback to savepoint
BEGIN;
  CREATE TEMP TABLE tmp_data (id INT);
  SAVEPOINT sp1;
  INSERT INTO tmp_data VALUES (1);
  ROLLBACK TO SAVEPOINT sp1;

  SELECT * FROM tmp_data;  -- EMPTY β€” rollback worked

  -- BUT: DROP TEMP TABLE does NOT rollback
  SAVEPOINT sp2;
  DROP TABLE tmp_data;
  ROLLBACK TO SAVEPOINT sp2;

  SELECT * FROM tmp_data;  -- ERROR: table doesn't exist!

6. Savepoint and Triggers:

-- If trigger throws exception inside nested transaction:
CREATE TRIGGER order_before_insert
    BEFORE INSERT ON orders
    FOR EACH ROW
    EXECUTE FUNCTION validate_order();

-- If trigger fails:
-- Savepoint rollback undoes INSERT
-- BUT: trigger side-effects (e.g., sequence nextval) are not rolled back

Performance Implications

Operation Latency Throughput Impact
Savepoint creation ~0.1ms Negligible
Savepoint release ~0.05ms Negligible
Rollback to savepoint ~0.5-2ms (depends on change count) Low
10 nested savepoints ~1-3ms total -2-5%
100 nested savepoints ~10-30ms -10-15% (undo log growth)

Concrete numbers (Spring Boot 3.x, PostgreSQL 15, HikariCP pool=50):

  • REQUIRED only: ~25,000 TPS
  • REQUIRED + 1 NESTED: ~24,000 TPS (savepoint overhead ~4%)
  • REQUIRED + 10 NESTED: ~22,000 TPS (cumulative savepoint overhead)
  • REQUIRED + 1 REQUIRES_NEW: ~20,000 TPS (suspend/resume ~20%)

Memory overhead:

  • Savepoint object: ~500 bytes (JDBC) + DB-side state
  • PostgreSQL: savepoint in shared memory, ~1KB per savepoint
  • MySQL: savepoint in InnoDB trx struct, ~2KB per savepoint
  • Undo log retention: with long transactions and savepoints β€” dead tuples accumulate

Memory Implications

  • JDBC Savepoint: ~500 bytes per savepoint object (client-side)
  • DB-side savepoint state: 1-2KB per savepoint in shared memory
  • Undo log / dead tuples: Each operation inside savepoint creates undo records. On rollback β€” undo records not freed until main commit.
  • Hibernate L1 cache: May contain stale entities after savepoint rollback β€” up to several MB for large operations.
  • Connection holder state: Spring stores savepoint reference in TransactionStatus β€” ~200 bytes per nested level.

Concurrency Aspects

NESTED concurrency model:

Thread-1: outerMethod(REQUIRED)
  β†’ nestedService.doWork(NESTED) β†’ savepoint sp1
    β†’ If exception: rollback to sp1 (only Thread-1 affected)
  β†’ nestedService.doWork2(NESTED) β†’ savepoint sp2
    β†’ commit sp2 (release)
  β†’ commit outer (commit all)

Thread-2: outerMethod(REQUIRED)
  β†’ nestedService.doWork(NESTED) β†’ savepoint sp3

Thread-1 and Thread-2 use different connections β†’ independent
Savepoints visible only within their own transaction

Savepoint and lock behavior:

-- Row locks acquired inside savepoint released on rollback to savepoint
BEGIN;
  SAVEPOINT sp1;
  UPDATE accounts SET balance = 100 WHERE id = 1;  -- row lock acquired
  ROLLBACK TO SAVEPOINT sp1;
  -- Row lock released!

-- BUT: DDL inside savepoint (PostgreSQL):
  SAVEPOINT sp2;
  ALTER TABLE orders ADD COLUMN new_col TEXT;  -- DDL
  ROLLBACK TO SAVEPOINT sp2;
  -- DDL NOT rolled back to savepoint! (DDL implicit commit)

Real Production Scenario

Situation: Log aggregation platform (2024), Spring Boot 3.1, PostgreSQL, ingestion 50,000 events/sec.

Problem: During batch log insertion (1000 events/batch), one malformed event caused rollback of the entire batch. Throughput dropped 30% due to reprocessing.

Original code:

@Transactional
public void ingestBatch(List<LogEvent> events) {
    for (LogEvent event : events) {
        LogEvent validated = validate(event);  // May throw ValidationException
        logRepo.save(validated);
    }
    // One ValidationException β†’ rollback entire batch (1000 events lost)
}

Attempt #1: try-catch inside REQUIRED

@Transactional
public void ingestBatch(List<LogEvent> events) {
    for (LogEvent event : events) {
        try {
            LogEvent validated = validate(event);
            logRepo.save(validated);
        } catch (ValidationException e) {
            failedCount++;
            // Problem: transaction marked rollback-only!
            // On commit: UnexpectedRollingBackException
        }
    }
}

Final solution: NESTED

@Autowired private EventImportService eventImportService;

@Transactional
public void ingestBatch(List<LogEvent> events) {
    int success = 0, failed = 0;

    for (LogEvent event : events) {
        try {
            eventImportService.importEvent(event);  // NESTED
            success++;
        } catch (ValidationException e) {
            failed++;
            // Only this event rolled back to savepoint
        }
    }

    metrics.record(success, failed);
    // COMMIT β€” all successful events saved
}

@Service
public class EventImportService {

    @Transactional(propagation = Propagation.NESTED)
    public void importEvent(LogEvent event) {
        LogEvent validated = validate(event);  // ValidationException β†’ savepoint rollback
        logRepo.save(validated);
        // No exception β†’ savepoint released
    }
}

Result:

  • Batch completion rate: from 70% (30% rollback) to 99.5%
  • Throughput: from 35,000 to 48,000 events/sec (+37%)
  • Latency p99: from 200ms to 50ms (fewer retries)
  • Trade-off: ~3% overhead from savepoint management

Monitoring and Diagnostics

Track savepoint creation and rollback:

@Component
public class SavepointMonitor implements TransactionSynchronization {

    private final MeterRegistry registry;

    @Override
    public void beforeCommit(boolean readOnly) {
        // Track savepoint usage
    }

    @Override
    public void afterCompletion(int status) {
        if (status == STATUS_ROLLED_BACK) {
            Counter.builder("transaction.savepoint.rollback")
                .increment();
        }
    }
}

// Register
TransactionSynchronizationManager.registerSynchronization(new SavepointMonitor(registry));

Hibernate L1 cache monitoring:

@PersistenceUnit
private EntityManagerFactory emf;

public void checkL1CacheSize() {
    SessionFactoryImpl sf = emf.unwrap(SessionFactoryImpl.class);
    for (Session s : sf.getSessions()) {
        PersistenceContext pc = s.getPersistenceContext();
        int entityCount = pc.getEntitiesByKey().size();
        if (entityCount > THRESHOLD) {
            log.warn("Large L1 cache after nested operations: {}", entityCount);
        }
    }
}

Best Practices for Highload

  1. Limit nesting depth β€” no more than 10-20 nested savepoints per transaction.
  2. Use NESTED only for partial failure tolerance β€” not for business logic.
  3. Flush + Clear after nested rollback with Hibernate:
    try {
        nestedService.doWork();
    } catch (Exception e) {
        em.clear();  // Required after savepoint rollback
    }
    
  4. Avoid sequence dependencies inside nested transactions β€” gaps are inevitable.
  5. Monitor savepoint rollback rate β€” high rate indicates validation problems, not transaction issues.

🎯 Interview Cheat Sheet

Must know:

  • NESTED creates a JDBC savepoint within the existing transaction β€” NOT a new physical transaction
  • If outer tx rolls back β†’ nested also rolls back. If nested rolls back β†’ outer can continue
  • Key difference from REQUIRES_NEW: NESTED = one commit at the end, REQUIRES_NEW = independent commit
  • NESTED doesn’t work with JTA β€” Spring falls back to REQUIRES_NEW
  • Hibernate L1 cache is NOT rolled back with savepoint β€” need em.flush() + em.clear()
  • Savepoints are per-DB: MySQL auto-releases after rollback, PostgreSQL allows reuse

Common follow-up questions:

  • NESTED vs REQUIRES_NEW? β€” NESTED: savepoint, single commit; REQUIRES_NEW: suspend + new tx, independent commit
  • What happens if outer tx rolls back with NESTED? β€” Everything rolls back (savepoint included)
  • Does NESTED work with JTA? β€” No, JTA doesn’t support savepoints, falls back to REQUIRES_NEW
  • Why is Hibernate L1 cache a problem with NESTED? β€” PersistenceContext not rolled back with savepoint

Red flags (DO NOT say):

  • β€œNESTED creates a new transaction” β€” it creates a savepoint within the same transaction
  • β€œNESTED works with JTA” β€” JTA doesn’t support savepoints
  • β€œNested data is visible to other transactions immediately” β€” only visible after common commit

Related topics:

  • [[13. What is Propagation in Spring]]
  • [[15. What is the difference between REQUIRED and REQUIRES_NEW]]
  • [[18. What is rollback in transactions]]