Java
spring-boot

Spring Boot 1.5で複数データベースを扱うウェブアプリケーションのサンプル

More than 1 year has passed since last update.


概要

Spring Bootを利用した複数のデータベースを扱うウェブアプリケーションのサンプルコードです。

環境


  • Windows10 Professional

  • Java 1.8.0_131

  • Spring Boot 1.5.3

参考


データベース

アプリケーションが扱うデータベースが下図のように3つあり、1つ(test_dev3)はアプリケーション共通のデータベース、残りの2つ(test_dev1,test_dev2)はマルチテナント的な扱いをするという想定です。

test_dev1とtest_dev2のデータベースのスキーマは同一で格納するデータが異なります。


  • test_dev1をテナントA用のデータベース

  • test_dev2をテナントB用のデータベース

  • test_dev3をアプリケーション共通のデータベース

このサンプルアプリケーションでは、リクエストパラメータの値でtest_dev1とtest_dev2のデータベースを切り替えます。

またサンプルアプリケーションなので扱うテーブルに特に意味はありません(適当です)。

なお、JpaTransactionManagerでは複数のデータベース(データソース)をまたがるトランザクションは実行できないようです。


構造図

ds.png


データソースの設定

データソースはapplication.ymlに下記の通り設定します。


application.yml

spring:

datasource:
dev1:
url: jdbc:postgresql://localhost:5432/test_dev1
username: user_dev1
password: pass1
driverClassName: org.postgresql.Driver
defaultAutoCommit: false
defaultReadOnly: false
validationQuery: SELECT 'dev1'
timeBetweenEvictionRunsMillis: 3600000
dev2:
url: jdbc:postgresql://localhost:5432/test_dev2
username: user_dev2
password: pass2
driverClassName: org.postgresql.Driver
defaultAutoCommit: false
defaultReadOnly: false
validationQuery: SELECT 'dev2'
timeBetweenEvictionRunsMillis: 3600000
dev3:
url: jdbc:postgresql://localhost:5432/test_dev3
username: user_dev3
password: pass3
driverClassName: org.postgresql.Driver
defaultAutoCommit: false
defaultReadOnly: false
validationQuery: SELECT 'dev3'
timeBetweenEvictionRunsMillis: 3600000
jpa:
showSql: true
formatSql: true

logging:
level:
root: INFO
org.springframework: INFO
# org.springframework.orm.jpa.JpaTransactionManager: DEBUG
# org.springframework.transaction: TRACE
org.hibernate: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
# org.hibernate.transaction: DEBUG
# org.hibernate.jpa.internal: DEBUG
org.hibernate.event.internal: DEBUG
org.hibernate.engine.transaction.internal: DEBUG
org.hibernate.internal.util: DEBUG



データソースの設定コード


test_dev1

テナントA用データソースの設定です。

プロパティファイルの設定値からデータソースをビルドするだけの実装になります。エンティティマネージャ、トランザクションマネージャは別のクラスで実装します。


Dev1DataSourceConfigure

package com.example.datasource;

import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;

@Configuration
public class Dev1DataSourceConfigure {
public static final String DEV1_DATASOURCE = "dev1DataSource";

@ConfigurationProperties(prefix = "spring.datasource.dev1")
@Bean(DEV1_DATASOURCE)
public DataSource dataSource() {
DataSource dev1 = DataSourceBuilder.create().build();
return dev1;
}
}



test_dev2

テナントB用データソースの設定です。

テナントAと同様の実装です。


Dev2DataSourceConfigure

package com.example.datasource;

import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class Dev2DataSourceConfigure {
public static final String DEV2_DATASOURCE = "dev2DataSource";

@ConfigurationProperties(prefix = "spring.datasource.dev2")
@Bean(DEV2_DATASOURCE)
public DataSource dataSource() {
DataSource dev2 = DataSourceBuilder.create().build();
return dev2;
}
}



test_dev1とtest_dev2を束ねるデータソースの設定

上記で実装した各テナント別のデータソースを参照するエンティティマネージャ、トランザクションマネージャを実装します。

実装のポイントは


  • (1) : orderを指定します。この値はデータソースの切り替えを行うAOPのorderより大きな値にします。

  • (2) : AbstractRoutingDataSourceを継承したクラスのインスタンスを生成し、スイッチするデータソースを設定します。


MultiDataSourceConfigure

package com.example.datasource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

import static com.example.datasource.Dev1DataSourceConfigure.DEV1_DATASOURCE;
import static com.example.datasource.Dev2DataSourceConfigure.DEV2_DATASOURCE;
import static com.example.datasource.MultiDataSourceConfigure.ENTITY_MANAGER;
import static com.example.datasource.MultiDataSourceConfigure.REPOSITORY_PACKAGE;
import static com.example.datasource.MultiDataSourceConfigure.TRANSACTION_MANAGER;

@Configuration
@EnableTransactionManagement(order = 100) // (1)
@EnableJpaRepositories(
basePackages = {REPOSITORY_PACKAGE},
entityManagerFactoryRef = ENTITY_MANAGER,
transactionManagerRef = TRANSACTION_MANAGER
)
public class MultiDataSourceConfigure {

public static final String REPOSITORY_PACKAGE = "com.example.repository.tenantds";
public static final String ENTITY_PACKAGES = "com.example.entity.tenantds";

public static final String ENTITY_MANAGER = "multiEntityManagerFactory";
public static final String TRANSACTION_MANAGER = "multiTransactionManager";

public static final String MULTI_DATASOURCE_PU = "multiDataSourcePersistenceUnit";
public static final String MULTI_DATASOURCE = "multiDataSource";

@Autowired
@Qualifier(DEV1_DATASOURCE)
private DataSource dev1;

@Autowired
@Qualifier(DEV2_DATASOURCE)
private DataSource dev2;

@Bean(MULTI_DATASOURCE)
public RoutingDataSourceResolver multiDataSource() { // (2)
RoutingDataSourceResolver resolver = new RoutingDataSourceResolver();

// スイッチするデータソースを設定
Map<Object, Object> dataSources = new HashMap<Object,Object>();
dataSources.put(DEV1_DATASOURCE, dev1);
dataSources.put(DEV2_DATASOURCE, dev2);

resolver.setTargetDataSources(dataSources);
resolver.setDefaultTargetDataSource(dev1);
return resolver;
}

@Bean(ENTITY_MANAGER)
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder) {
LocalContainerEntityManagerFactoryBean emf = builder
.dataSource(multiDataSource())
.persistenceUnit(MULTI_DATASOURCE_PU)
.packages(ENTITY_PACKAGES)
.build();
return emf;
}

@Bean(TRANSACTION_MANAGER)
public PlatformTransactionManager transactionManager(
@Autowired @Qualifier(ENTITY_MANAGER) EntityManagerFactory entityManagerFactory) {
JpaTransactionManager jtm = new JpaTransactionManager();
jtm.setEntityManagerFactory(entityManagerFactory);
//jtm.setDataSource(multiDataSource());
return jtm;
}
}



test_dev1とtest_dev2をスイッチする設定

Spring FrameworkのAbstractRoutingDataSourceを継承してスイッチする処理を実装します。


RoutingDataSourceResolver

package com.example.datasource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import static com.example.datasource.Dev1DataSourceConfigure.DEV1_DATASOURCE;
import static com.example.datasource.Dev2DataSourceConfigure.DEV2_DATASOURCE;

public class RoutingDataSourceResolver extends AbstractRoutingDataSource {

@Override
protected Object determineCurrentLookupKey() {
if (MultiDataSourceContextHolder.getTenantType() == null) {
return DEV1_DATASOURCE;
}
// テナントとデータソースのマッピング
switch (MultiDataSourceContextHolder.getTenantType()) {
case TENANT_A:
return DEV1_DATASOURCE;
case TENANT_B:
return DEV2_DATASOURCE;
default:
throw new RuntimeException("unknown tenant");
}
}
}



MultiDataSourceContextHolder

package com.example.datasource;

public class MultiDataSourceContextHolder {
private static ThreadLocal<TenantType> contextHolder = new ThreadLocal<>();

public static void setTenantType(TenantType tenantType) {
if (tenantType == null) {
throw new NullPointerException();
}
contextHolder.set(tenantType);
}

public static TenantType getTenantType() {
return contextHolder.get();
}

public static void clearTenantType() {
contextHolder.remove();
}
}


テナントの種類を管理するEnum。


TenantType

package com.example.datasource;

import java.util.Arrays;
import java.util.Optional;

public enum TenantType {
/* test_dev1 */
TENANT_A("aaa"),
/* test_dev2 */
TENANT_B("bbb")
;

private final String tenantName;

TenantType(String tenantName) {
this.tenantName = tenantName;
}

public String getTenantName() {
return this.tenantName;
}

public static TenantType byName(final String tenantName) {
Optional<TenantType> tenantType = Arrays.stream(TenantType.values())
.filter(t -> t.getTenantName().equals(tenantName))
.findFirst();
if (!tenantType.isPresent()) {
throw new IllegalStateException("unknown tenant name : [" + tenantName + "]");
}
return tenantType.get();
}
}



AOPでデータソースの切り替えを行う

データソースを切り替えるにはいくつか方法がありますが、このサンプルアプリケーションではSpring AOPの機能を使って行います。

下記のアノテーションを付与したクラスの「tenant」という引数があるメソッドに対して設定します。

このtenantという引数の値でデータソースの切り替えを行います。


SwitchingDataSource

package com.example.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface SwitchingDataSource {
}


Orderアノテーションでトランザクションより先にデータソースの切り替えが行われるように調整します。

実装のポイントは


  • (1) : トランザクションマネージャで設定した値より小さな値にします。

  • (2) : データソースを切り替えるポイントカットの指定です。アノテーションと引数名で絞り込みを行います。

  • (3) : 同上


SwitchingDataSourceAop

package com.example.aop;

import com.example.datasource.MultiDataSourceContextHolder;
import com.example.datasource.TenantType;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Order(99) // (1)
@Aspect
@Component
public class SwitchingDataSourceAop {

@Around("@annotation(swds) && args(tenant,..)") // (2)
public Object switchingForMethod(ProceedingJoinPoint pjp, SwitchingDataSource swds, String tenant) throws Throwable {
try {
switching(tenant);
Object result = pjp.proceed();
return result;
} finally {
clear();
}
}

@Around("@within(swds) && args(tenant,..)") // (3)
public Object switchingForClass(ProceedingJoinPoint pjp, SwitchingDataSource swds, String tenant) throws Throwable {
try {
switching(tenant);
Object result = pjp.proceed();
return result;
} finally {
clear();
}
}

private void switching(String tenant) {
TenantType tenantType = TenantType.byName(tenant);
MultiDataSourceContextHolder.setTenantType(tenantType);
}
private void clear() {
MultiDataSourceContextHolder.clearTenantType();
}
}



test_dev3用のデータソース

アプリケーション共通のデータソースは切り替えを行う必要がないので、このクラスでエンティティマネージャ、トランザクションマネージャの実装まで行います。

ちなみにこの設定がデフォルトとなるようにPrimaryアノテーションを付けています。


SingleDataSourceConfigure

package com.example.datasource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;

import java.util.Properties;

import static com.example.datasource.SingleDataSourceConfigure.ENTITY_MANAGER;
import static com.example.datasource.SingleDataSourceConfigure.REPOSITORY_PACKAGES;
import static com.example.datasource.SingleDataSourceConfigure.TRANSACTION_MANAGER;

@Configuration
@EnableTransactionManagement(order = 101)
@EnableJpaRepositories(
basePackages = {REPOSITORY_PACKAGES},
entityManagerFactoryRef = ENTITY_MANAGER,
transactionManagerRef = TRANSACTION_MANAGER
)
public class SingleDataSourceConfigure {

public static final String REPOSITORY_PACKAGES = "com.example.repository.appds";
public static final String ENTITY_PACKAGES = "com.example.entity.appds";

public static final String ENTITY_MANAGER = "entityManagerFactory";
public static final String TRANSACTION_MANAGER = "transactionManager";

public static final String SINGLE_DATASOURCE_PU = "singleDataSourcePersistenceUnit";
public static final String SINGLE_DATASOURCE = "singleDataSource";

@Primary
@ConfigurationProperties(prefix = "spring.datasource.dev3")
@Bean(SINGLE_DATASOURCE)
public DataSource singleDataSource() {
DataSource dataSource = DataSourceBuilder.create().build();
return dataSource;
}

@Primary
@Bean(ENTITY_MANAGER)
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
EntityManagerFactoryBuilder builder) {

LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(singleDataSource());
emf.setPersistenceUnitName(SINGLE_DATASOURCE_PU);
emf.setPackagesToScan(ENTITY_PACKAGES);

JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
emf.setJpaVendorAdapter(vendorAdapter);
emf.setJpaProperties(additionalProperties());

return emf;
}

@Primary
@Bean(TRANSACTION_MANAGER)
public PlatformTransactionManager transactionManager(
@Autowired @Qualifier(ENTITY_MANAGER) EntityManagerFactory entityManagerFactory) {
JpaTransactionManager jtm = new JpaTransactionManager();
jtm.setEntityManagerFactory(entityManagerFactory);
//jtm.setDataSource(singleDataSource());
return jtm;
}

@Bean
public PersistenceExceptionTranslationPostProcessor exceptionTranslation(){
return new PersistenceExceptionTranslationPostProcessor();
}

private Properties additionalProperties() {
Properties properties = new Properties();
properties.setProperty("hibernate.show_sql", "true");
properties.setProperty("hibernate.format_sql", "true");
properties.setProperty("hibernate.use_sql_comments", "true");
properties.setProperty("hibernate.generate_statistics", "false");
return properties;
}

}



エンティティとリポジトリ

下記のようにテナント用とアプリケーション共通用をパッケージを分けて管理します。

なお、エンティティ、リポジトリより上位のサービスクラスはパッケージを分ける必要が無いので分けていません。

com.example

|
+--- entity
| |
| +--- tenantds //tenant datasource
| | |
| | +--- Todo
| |
| +--- appds //application datasource
| |
| +--- Memo
|
+--- repository
| |
| +--- tenantds
| | |
| | +--- TodoRepository
| |
| +--- appds
| |
| +--- MemoRepository
|
+--- service
|
+--- impl
| |
| +--- TodoService
| +--- MemoService
|
+--- TodoServiceImpl
+--- MemoServiceImpl


エンティティ

パッケージを分けるだけで実装に特別なところはありません。


Todo

package com.example.entity.tenantds;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import java.util.Date;

@Entity
@Table(name="todo")
@SequenceGenerator(name = "todo_id_gen", sequenceName = "todo_id_seq", allocationSize = 1)
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="title")
private String title;
@Column(name="done")
private Boolean done;
@Column(name="updated")
@Temporal(TemporalType.TIMESTAMP)
private Date updated;
}



Memo

package com.example.entity.appds;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.SequenceGenerator;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import java.util.Date;

@Entity
@Table(name="memo")
@SequenceGenerator(name = "memo_id_gen", sequenceName = "memo_id_seq", allocationSize = 1)
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Memo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name="title")
private String title;
@Column(name="description")
private String description;
@Column(name="done")
private Boolean done;
@Column(name="updated")
@Temporal(TemporalType.TIMESTAMP)
private Date updated;
}



リポジトリ


TodoRepository

package com.example.repository.tenantds;

import com.example.entity.multids.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import javax.persistence.LockModeType;

public interface TodoRepository extends JpaRepository<Todo, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Todo findById(Long id);
}



MemoRepository

package com.example.repository.appds;

import com.example.entity.singleds.Memo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

import javax.persistence.LockModeType;

public interface MemoRepository extends JpaRepository<Memo, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Memo findById(Long id);
}



コントローラーとサービス


コントローラー

リクエストパラメータ「tenant」の値でデータソースを切り替えます。



/todo/list?tenant=<テナントコード>



TodoController

package com.example.controller;

import com.example.entity.tenantds.Todo;
import com.example.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.Date;
import java.util.List;

@RequestMapping(path = "todo")
@RestController
public class TodoController {

@Autowired
private TodoService service;

@GetMapping(path = "list", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public List<Todo> list(
@RequestParam(value = "tenant", required = true) String tenant) throws Exception {
List<Todo> lists = service.list(tenant);
return lists;
}

@GetMapping(path = "update", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Todo update(
@RequestParam(value = "tenant", required = true) String tenant,
@RequestParam(value = "id", required = true) Long id,
@RequestParam(value = "title") String title,
@RequestParam(value = "done", defaultValue = "FALSE") Boolean done,
@RequestParam(value = "wt", defaultValue = "30") Long waittime) throws Exception {
Todo todo = service.lockAndUpdate(tenant, id, title, done, new Date(), waittime);
return todo;
}

@GetMapping(path = "insert", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Todo insert(
@RequestParam(value = "tenant", required = true) String tenant,
@RequestParam(name = "title", required = true) String title) throws Exception {
Todo todo = service.insert(tenant, title, Boolean.FALSE, new Date());
return todo;
}
}



サービス


TodoService

package com.example.service;

import com.example.entity.tenantds.Todo;

import java.util.Date;
import java.util.List;

public interface TodoService {
List<Todo> list(String tenant);
Todo lockAndUpdate(String tenant, Long id, String title, Boolean done, Date updated, Long waittime) throws Exception;
Todo insert(String tenant, String title, Boolean done, Date updated) throws Exception;
}


実装にいくつかポイントがあります。


  • (1) : データソースの切り替えを行うクラスに、このアノテーションを付与します。

  • (2) : トランザクションマネージャを明記します。

  • (3) : 1番目にtenantという名前の引数を取ります。

(1)と(3)はAOPでデータソースを切り替えるために必要な実装になります。


TodoServiceImpl

package com.example.service.impl;

import com.example.aop.SwitchingDataSource;
import com.example.entity.tenantds.Todo;
import com.example.repository.tenantds.TodoRepository;
import com.example.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;

import static com.example.datasource.MultiDataSourceConfigure.TRANSACTION_MANAGER;

@Service
@SwitchingDataSource // (1)
@Transactional(readOnly = true, timeout = 10, transactionManager = TRANSACTION_MANAGER) // (2)
public class TodoServiceImpl implements TodoService {

@Autowired
private TodoRepository repository;

@Override
public List<Todo> list(String tenant) { // (3)
Sort sort = new Sort(new Order(Sort.Direction.DESC, "updated"), new Order(Sort.Direction.DESC, "id"));
List<Todo> lists = repository.findAll(sort);
return lists;
}

@Transactional(readOnly = false, timeout = 120, rollbackFor = Exception.class, transactionManager = TRANSACTION_MANAGER)
@Override
public Todo lockAndUpdate(String tenant, Long id, String title, Boolean done, Date updated, Long waittime) throws Exception {

Todo todo = repository.findById(id);

todo.setTitle(title);
todo.setDone(done);
todo.setUpdated(updated);

// タイムアウトテスト用コード
try {
TimeUnit.SECONDS.sleep(waittime);
} catch (InterruptedException e) {
System.out.println("timeout:" + e.getMessage());
throw new Exception("timeout");
}

repository.save(todo);

return todo;
}

@Transactional(readOnly = false, rollbackFor = Exception.class, transactionManager = TRANSACTION_MANAGER)
@Override
public Todo insert(String tenant, String title, Boolean done, Date updated) throws Exception {
Todo todo = Todo.builder().title(title).done(done).updated(updated).build();
repository.save(todo);
return todo;
}
}