├── .atlassian └── OWNER ├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .sdkmanrc ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.gradle.kts ├── docs ├── classes-overview.md ├── classes-overview.png ├── classes-overview.puml ├── consistency.md ├── consistency.png ├── consistency.puml ├── dual-connection-states.md ├── dual-connection-states.png ├── dual-connection-states.puml ├── high-level-overview.png ├── high-level-overview.puml ├── release │ ├── releasing.md │ └── trigger-gha-release.mp4 ├── split-instrumentation.md ├── split-instrumentation.png ├── split-instrumentation.puml ├── switching-between-main-and-replica.md └── uml.md ├── gradle ├── dependency-locks │ ├── annotationProcessor.lockfile │ ├── compileClasspath.lockfile │ ├── runtimeClasspath.lockfile │ ├── signatures.lockfile │ ├── testAnnotationProcessor.lockfile │ ├── testCompileClasspath.lockfile │ └── testRuntimeClasspath.lockfile └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main └── java │ └── com │ └── atlassian │ └── db │ └── replica │ ├── api │ ├── AuroraConnectionDetails.java │ ├── AuroraMultiReplicaConsistency.java │ ├── AuroraPostgresLsnReplicaConsistency.java │ ├── Database.java │ ├── DualConnection.java │ ├── PessimisticPropagationConsistency.java │ ├── SqlCall.java │ ├── ThrottledCache.java │ ├── exception │ │ ├── ConnectionCouldNotBeClosedException.java │ │ └── ReadReplicaConnectionCreationException.java │ ├── jdbc │ │ ├── JdbcProtocol.java │ │ └── JdbcUrl.java │ └── reason │ │ ├── Reason.java │ │ └── RouteDecision.java │ ├── internal │ ├── ClientInfo.java │ ├── ConnectionOperation.java │ ├── ConnectionParameters.java │ ├── DecisionAwareReference.java │ ├── DelegatingPreparedStatement.java │ ├── ForwardCall.java │ ├── LazyReference.java │ ├── LockBasedThrottledCache.java │ ├── MonotonicMemoryCache.java │ ├── NetworkTimeout.java │ ├── NoCacheSuppliedCache.java │ ├── NoOpDirtyConnectionCloseHook.java │ ├── NotLoggingLogger.java │ ├── ReadReplicaUnsupportedOperationException.java │ ├── ReplicaCallableStatement.java │ ├── ReplicaConnectionProvider.java │ ├── ReplicaPreparedStatement.java │ ├── ReplicaStatement.java │ ├── RouteDecisionBuilder.java │ ├── SqlFunction.java │ ├── SqlQuery.java │ ├── SqlRunnable.java │ ├── StatementOperation.java │ ├── Warnings.java │ ├── aurora │ │ ├── AuroraCluster.java │ │ ├── AuroraClusterDiscovery.java │ │ ├── AuroraEndpoint.java │ │ ├── AuroraEndpoints.java │ │ ├── AuroraJdbcUrl.java │ │ ├── AuroraReplicaNode.java │ │ ├── AuroraReplicasDiscoverer.java │ │ ├── RdsDns.java │ │ ├── ReadReplicaDiscovererCreationException.java │ │ ├── ReadReplicaDiscoveryOperationException.java │ │ ├── ReadReplicaNodeLabelingOperationException.java │ │ └── ReplicaNode.java │ ├── circuitbreaker │ │ ├── BreakOnNotSupportedOperations.java │ │ ├── BreakerCallableStatement.java │ │ ├── BreakerConnection.java │ │ ├── BreakerHandler.java │ │ ├── BreakerPreparedStatement.java │ │ ├── BreakerState.java │ │ ├── BreakerStatement.java │ │ ├── CircuitBreaker.java │ │ └── ClosedBreaker.java │ ├── logs │ │ ├── ConnectionProviderLogger.java │ │ ├── DelegatingLazyLogger.java │ │ ├── LazyLogger.java │ │ ├── NoopLazyLogger.java │ │ ├── ReplicaConsistencyLogger.java │ │ ├── StateAwareLogger.java │ │ └── TaggedLogger.java │ ├── state │ │ ├── ConnectionState.java │ │ ├── NoOpStateListener.java │ │ ├── State.java │ │ └── StateListener.java │ └── util │ │ ├── Comparables.java │ │ └── ThreadSafe.java │ └── spi │ ├── Cache.java │ ├── ConnectionProvider.java │ ├── DatabaseCall.java │ ├── DirtyConnectionCloseHook.java │ ├── Logger.java │ ├── ReplicaConnectionPerUrlProvider.java │ ├── ReplicaConnectionProvider.java │ ├── ReplicaConsistency.java │ └── SuppliedCache.java └── test └── java └── com └── atlassian └── db └── replica ├── api ├── AuroraClusterDiscoveryTest.java ├── AuroraClusterMock.java ├── AuroraMultiReplicaConsistencyTest.java ├── CacheLoader.java ├── ConnectionStateTest.java ├── Queries.java ├── ReplicaConsistencyMock.java ├── TestConnectionWarnings.java ├── TestConsistency.java ├── TestCreateStatements.java ├── TestDualConnection.java ├── TestStatement.java ├── TestTransactions.java ├── ThrottledCacheTest.java ├── ThrottledSequenceCacheTest.java ├── TickingClock.java ├── WaitingWork.java ├── circuitbreaker │ └── TestCircuitBreaker.java └── mocks │ ├── CircularConsistency.java │ ├── ConnectionMock.java │ ├── ConnectionProviderMock.java │ ├── MockLogger.java │ ├── NoOpConnection.java │ ├── NoOpConnectionProvider.java │ ├── NoOpPreparedStatement.java │ ├── ReadOnlyAwareConnection.java │ └── SingleConnectionProvider.java ├── internal ├── AuroraPostgresLsnReplicaConsistencyTest.java ├── DefaultReplicaConnectionPerUrlProvider.java ├── LazyReferenceTest.java ├── LsnReplicaConsistency.java ├── LsnReplicaConsistencyTest.java ├── PessimisticPropagationConsistencyTest.java ├── VolatileCache.java ├── aurora │ └── AuroraEndpointTest.java └── util │ ├── ConnectionSupplier.java │ └── TestComparables.java └── it ├── DualConnectionIT.java ├── DualConnectionPerfIT.java ├── PostgresConnectionProvider.java ├── ReplicaStatementIT.java ├── consistency └── WaitingReplicaConsistency.java └── example └── aurora ├── AuroraClusterTest.java ├── app ├── User.java └── Users.java ├── replica ├── AuroraConnectionProvider.java ├── ConsistencyFactory.java └── api │ ├── AuroraSequence.java │ ├── SequenceReplicaConsistency.java │ └── SynchronousWriteConsistency.java └── utils ├── DecisionLog.java └── ReplicationLag.java /.atlassian/OWNER: -------------------------------------------------------------------------------- 1 | [mkwidzinski] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | inputs: 8 | release: 9 | description: 'Release? Major/Minor/Patch/None' 10 | required : true 11 | default: 'None' 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | id-token: write 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v2 22 | with: 23 | fetch-depth: 0 24 | - name: Cache Gradle 25 | uses: actions/cache@v2 26 | with: 27 | path: ~/.gradle 28 | key: ${{ runner.os }}-${{ hashFiles('gradle') }} 29 | - name: Build 30 | run: ./gradlew build 31 | - name: Upload test reports 32 | if: always() 33 | uses: actions/upload-artifact@v2 34 | with: 35 | name: test-reports 36 | path: build/reports/tests 37 | - name: Upload pitest reports 38 | if: always() 39 | uses: actions/upload-artifact@v2 40 | with: 41 | name: pitest-reports 42 | path: build/reports/pitest 43 | - name: Get publish token 44 | id: publish-token 45 | if: github.event.inputs.release != null && github.event.inputs.release != 'None' 46 | uses: atlassian-labs/artifact-publish-token@v1.0.1 47 | - name: Release 48 | if: github.event.inputs.release != null && github.event.inputs.release != 'None' 49 | env: 50 | atlassian_private_username: ${{ steps.publish-token.outputs.artifactoryUsername }} 51 | atlassian_private_password: ${{ steps.publish-token.outputs.artifactoryApiKey }} 52 | run: | 53 | ./gradlew markNextVersion -Prelease.incrementer=increment${{ github.event.inputs.release }} -Prelease.localOnly 54 | ./gradlew release -Prelease.customUsername=${{ secrets.REPOSITORY_ACCESS_TOKEN }} 55 | ./gradlew publish 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .artifactory 2 | .gradle 3 | build 4 | -------------------------------------------------------------------------------- /.sdkmanrc: -------------------------------------------------------------------------------- 1 | # Enable auto-env through the sdkman_auto_env config 2 | # Add key=value pairs of SDKs to use below 3 | java=11.0.15-tem 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @atlassian-labs/falcon 2 | * @atlassian-labs/jira-sre 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Submitting contributions or comments that you know to violate the intellectual property or privacy rights of others 15 | * Other unethical or unprofessional conduct 16 | 17 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 18 | By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 19 | 20 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 21 | 22 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting a project maintainer. Complaints will result in a response and be reviewed and investigated in a way that is deemed necessary and appropriate to the circumstances. Maintainers are obligated to maintain confidentiality with regard to the reporter of an incident. 23 | 24 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at [http://contributor-covenant.org/version/1/3/0/][version] 25 | 26 | [homepage]: http://contributor-covenant.org 27 | [version]: http://contributor-covenant.org/version/1/3/0/ 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to db-replica 2 | 3 | Thank you for considering a contribution to db-replica! Pull requests, issues and comments are welcome. For pull requests, please: 4 | 5 | * Add tests for new features and bug fixes 6 | * Follow the existing style 7 | * Separate unrelated changes into multiple pull requests 8 | 9 | See the existing issues for things to start contributing. 10 | 11 | For bigger changes, please make sure you start a discussion first by creating an issue and explaining the intended change. 12 | 13 | Check [the class diagram](docs/classes-overview.md) to understand how things are connected. 14 | 15 | Atlassian requires contributors to sign a Contributor License Agreement, known as a CLA. This serves as a record stating that the contributor is entitled to contribute the code/documentation/translation to the project and is willing to have it used in distributions and derivative works (or is willing to transfer ownership). 16 | 17 | Prior to accepting your contributions we ask that you please follow the appropriate link below to digitally sign the CLA. The Corporate CLA is for those who are contributing as a member of an organization and the individual CLA is for those contributing as an individual. 18 | 19 | * [CLA for corporate contributors](https://opensource.atlassian.com/corporate) 20 | * [CLA for individuals](https://opensource.atlassian.com/individual) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # db-replica 2 | ![CI](https://github.com/atlassian-labs/db-replica/workflows/CI/badge.svg) 3 | [![license](https://img.shields.io/badge/license-Apache%202.0-blue.svg?style=flat-square)](LICENSE) 4 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](CONTRIBUTING.md) 5 | 6 | Using database replicas unlocks horizontal scalability. Some replica designs force replicas to be read-only. 7 | Read-only queries can be sent to the replica, while others have to go to the main DB. 8 | The `db-replica` API automatically routes the queries to the correct node. 9 | It integrates at the `java.sql.Connection` level, so you don't have to hunt down hundreds of queries manually. 10 | 11 | ![High level overview](docs/high-level-overview.png "High level overview") 12 | 13 | ## Features 14 | 15 | - [Automatic switching between main and replica databases](docs/switching-between-main-and-replica.md). 16 | - [Configurable consistency model](docs/consistency.md). 17 | - [Configurable main/replica split instrumentation](docs/split-instrumentation.md). 18 | 19 | ## Usage 20 | 21 | The library is [not available via Maven Central Repository](https://github.com/atlassian-labs/db-replica/issues/18) yet. It can be accessed via 22 | [Atlassian Maven proxy](https://developer.atlassian.com/server/framework/atlassian-sdk/atlassian-maven-repositories-2818705/#atlassian-maven-proxy-). 23 | 24 | ```java 25 | import com.atlassian.db.replica.api.*; 26 | import java.sql.*; 27 | import java.time.*; 28 | 29 | class Example { 30 | 31 | private final ReplicaConsistency consistency = new PessimisticPropagationConsistency.Builder().build(); 32 | 33 | ResultSet queryReplicaOrMain(String sql) { 34 | try (ConnectionProvider connectionProvider = new PostgresConnectionProvider()) { 35 | Connection connection = DualConnection.builder(connectionProvider, consistency).build(); 36 | return connection.prepareStatement(sql).executeQuery(); 37 | } 38 | } 39 | } 40 | ``` 41 | 42 | ## Installation 43 | 44 | Maven: 45 | ```xml 46 | 47 | com.atlassian.db.replica 48 | db-replica 49 | 2.9.0 50 | 51 | ``` 52 | 53 | ## Documentation 54 | 55 | See Javadoc of classes in the `api` and `spi` packages. 56 | See [DualConnection states UML](docs/dual-connection-states.md). 57 | See [how to release the library](docs/release/releasing.md). 58 | 59 | ## Tests 60 | 61 | Run all checks: `./gradlew build` 62 | Run just the unit tests: `./gradlew test` 63 | Run mutation tests: `./gradlew pitest` 64 | 65 | ## Contributions 66 | 67 | Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. 68 | 69 | ## License 70 | 71 | Apache 2.0 licensed, see [LICENSE](LICENSE) file. 72 | 73 | [![With ❤️ from Atlassian][cheers img]](https://www.atlassian.com) 74 | 75 | [cheers img]: https://raw.githubusercontent.com/atlassian-internal/oss-assets/master/banner-cheers-light.png 76 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | } 5 | dependencies { 6 | classpath("info.solidsoft.gradle.pitest:gradle-pitest-plugin:1.4.7") 7 | } 8 | } 9 | 10 | plugins { 11 | `java-library` 12 | id("com.atlassian.performance.tools.gradle-release").version("0.7.3") 13 | id("info.solidsoft.pitest").version("1.6.0") 14 | } 15 | 16 | configure { 17 | junit5PluginVersion.set("0.12") 18 | avoidCallsTo.set(setOf("kotlin.jvm.internal", "kotlinx.coroutines")) 19 | mutators.set(setOf("STRONGER")) 20 | targetClasses.set(setOf("com.atlassian.db.replica.*")) 21 | targetTests.set(setOf("*Test")) 22 | threads.set(Runtime.getRuntime().availableProcessors()) 23 | outputFormats.set(setOf("XML", "HTML")) 24 | } 25 | 26 | tasks.wrapper { 27 | gradleVersion = "6.7" 28 | distributionType = Wrapper.DistributionType.BIN 29 | } 30 | 31 | dependencies { 32 | testImplementation("org.postgresql:postgresql:42.2.18") 33 | testImplementation(platform("org.junit:junit-bom:5.7.2")) 34 | testImplementation("org.junit.jupiter:junit-jupiter") 35 | testImplementation("org.assertj:assertj-core:3.19.0") 36 | testImplementation("org.mockito:mockito-core:3.11.0") 37 | testImplementation("org.mockito:mockito-junit-jupiter:3.11.0") 38 | testImplementation("org.threeten:threeten-extra:1.5.0") 39 | testImplementation("com.github.docker-java:docker-java-core:3.2.6") 40 | testImplementation("com.github.docker-java:docker-java-transport-httpclient5:3.2.6") 41 | testImplementation("com.h2database:h2:1.4.200") 42 | } 43 | 44 | configurations.all { 45 | resolutionStrategy { 46 | activateDependencyLocking() 47 | failOnVersionConflict() 48 | eachDependency { 49 | resolveBogusConflicts() 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * Third-party libraries often declare specific versions of their dependencies. 56 | * But they're de facto compatible with a wider range of versions. 57 | * This leads [ResolutionStrategy.failOnVersionConflict] to detect bogus conflicts. 58 | * Preferably, the lib authors would relax their version requirement. 59 | * Otherwise we have to find a compatible version and force it here. 60 | */ 61 | fun DependencyResolveDetails.resolveBogusConflicts() { 62 | when (requested.module.toString()) { 63 | "org.slf4j:slf4j-api" -> useVersion("1.7.30") 64 | } 65 | } 66 | 67 | java { 68 | sourceCompatibility = JavaVersion.VERSION_1_8 69 | targetCompatibility = JavaVersion.VERSION_1_8 70 | } 71 | 72 | tasks.compileJava { 73 | options.compilerArgs.add("-Xlint:deprecation") 74 | options.compilerArgs.add("-Xlint:unchecked") 75 | } 76 | 77 | tasks.withType { 78 | reports { 79 | junitXml.isEnabled = true 80 | } 81 | } 82 | 83 | tasks.test { 84 | useJUnitPlatform() 85 | filter { 86 | exclude("**/*IT.class") 87 | } 88 | } 89 | 90 | val testIntegration = task("testIntegration") { 91 | useJUnitPlatform() 92 | filter { 93 | include("**/*IT.class") 94 | } 95 | setForkEvery(1) 96 | maxParallelForks = 1 97 | } 98 | 99 | tasks["check"].dependsOn(testIntegration) 100 | 101 | group = "com.atlassian.db.replica" 102 | 103 | gradleRelease { 104 | atlassianPrivateMode = true 105 | } 106 | -------------------------------------------------------------------------------- /docs/classes-overview.md: -------------------------------------------------------------------------------- 1 | ## Classes diagram 2 | 3 | This diagram is intended to help understand how the library works internally. It may leak some implementation details and should not be used as API. 4 | 5 | ![Classes diagram](classes-overview.png) 6 | 7 | `DualConnection#Builder` creates [DualConnection](../src/main/java/com/atlassian/db/replica/api/DualConnection.java) when 8 | [CircuitBreaker](../src/main/java/com/atlassian/db/replica/spi/circuitbreaker/CircuitBreaker.java) is closed. 9 | The builder returns a connection to the main database when [CircuitBreaker](../src/main/java/com/atlassian/db/replica/spi/circuitbreaker/CircuitBreaker.java) 10 | is open. 11 | Every call that goes to the database directly through the connection or 12 | one of the `java.sql.Statement` implementations can be intercepted with 13 | [DatabaseCall](../src/main/java/com/atlassian/db/replica/spi/DatabaseCall.java). 14 | 15 | [ConnectionState](../src/main/java/com/atlassian/db/replica/internal/state/ConnectionState.java) is an internal class 16 | that is the source of truth to the current [State](../src/main/java/com/atlassian/db/replica/api/state/State.java). 17 | It can use [ConnectionProvider](../src/main/java/com/atlassian/db/replica/spi/ConnectionProvider.java) 18 | to obtain a connection to a database. It utilises 19 | [ReplicaConsistency](../src/main/java/com/atlassian/db/replica/spi/ReplicaConsistency.java) while transitioning 20 | between states. [StateListener](../src/main/java/com/atlassian/db/replica/spi/state/StateListener.java) is called on each transition. 21 | -------------------------------------------------------------------------------- /docs/classes-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/classes-overview.png -------------------------------------------------------------------------------- /docs/classes-overview.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | class "DualConnection#Builder" as builder 3 | builder : Connection build() 4 | 5 | class DualConnection 6 | interface ReplicaConsistency 7 | interface ConnectionProvider 8 | interface DatabaseCall 9 | interface StateListener 10 | interface CircuitBreaker 11 | class ConnectionState 12 | class State 13 | 14 | interface Statement 15 | interface PreparedStatement 16 | interface CallableStatement 17 | interface Connection 18 | 19 | 20 | builder::build --> DualConnection : create 21 | 22 | 23 | DualConnection --|> Connection 24 | PreparedStatement --|> Statement 25 | CallableStatement --|> Statement 26 | 27 | 28 | ConnectionState *-- ReplicaConsistency 29 | ConnectionState *-- ConnectionProvider 30 | ConnectionState *-- StateListener 31 | ConnectionState *-- Connection 32 | ConnectionState *-- State 33 | 34 | DualConnection *-- DatabaseCall 35 | DualConnection *-- Statement 36 | builder *-- CircuitBreaker 37 | DualConnection *-- ConnectionState 38 | 39 | 40 | 41 | 42 | @enduml 43 | -------------------------------------------------------------------------------- /docs/consistency.md: -------------------------------------------------------------------------------- 1 | ## Consistency 2 | 3 | [DualConnection](../src/main/java/com/atlassian/db/replica/api/DualConnection.java) consistency is controlled by 4 | [com.atlassian.db.replica.spi.ReplicaConsistency](../src/main/java/com/atlassian/db/replica/spi/ReplicaConsistency.java). 5 | 6 | Currently, there's no default consistency implementation. Consistency model highly depends on the database and 7 | application's needs. There's one example of the implementation for Postgres available in 8 | [the integration test](../src/test/java/com/atlassian/db/replica/it/DualConnectionIT.java). 9 | 10 | The easiest way to start is to use 11 | [PessimisticPropagationConsistency](../src/main/java/com/atlassian/db/replica/api/PessimisticPropagationConsistency.java). 12 | 13 | ![ReplicaConsistency](consistency.png "ReplicaConsistency") 14 | 15 | Every write operation is registered by `ReplicaConsistency#write` method. 16 | [DualConnection](../src/main/java/com/atlassian/db/replica/api/DualConnection.java) verifies 17 | the state of replica's consistency by calling `ReplicaConsistency#isConsistent` method. 18 | 19 | -------------------------------------------------------------------------------- /docs/consistency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/consistency.png -------------------------------------------------------------------------------- /docs/consistency.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | @startuml 4 | 5 | class DualConnection 6 | 7 | interface ReplicaConsistency{ 8 | void write(Connection main) 9 | boolean isConsistent(Supplier replica) 10 | } 11 | 12 | DualConnection *-- ReplicaConsistency 13 | DualConnection --> ReplicaConsistency::write : db write operation 14 | 15 | DualConnection --> ReplicaConsistency::isConsistent : validate consistency for db read operation 16 | 17 | 18 | @enduml 19 | 20 | 21 | @enduml 22 | -------------------------------------------------------------------------------- /docs/dual-connection-states.md: -------------------------------------------------------------------------------- 1 | `DualConnection` chooses between main and replica database connections at query time. 2 | The choose is postponed to a query time. When the first query arrives, one of the connections 3 | will be initialised (main or replica). There's a sequence of choices to determine which 4 | connection should be used. 5 | 6 | `is write context?` - it's the first and the simplest check. Some statement methods like 7 | `executeUpdate` are intended for writes. 8 | 9 | `is write query?` - validates SQL query. 10 | 11 | `is replica consistent?` - Uses provided `RelicaConsistency` implementation to determine if 12 | the replica is ready to serve consistent responses. 13 | 14 | ![DualConnection states](dual-connection-states.png "DualConnection states") 15 | 16 | If we ended in `ReplicaConnection` or `CommitedMain` state, then the next query will go the same way. 17 | `MainConnection` is a permanent state and `DualConnection` re-use main connection for 18 | next queries. 19 | 20 | ## States 21 | 22 | - `NoConnection` - when we create a new `DualConnection` it doesn't allocate 23 | any real database connection. 24 | - `ReplicaConnection` - connection to the replica database has been established. It will be 25 | re-used until we need to switch to either `MainConnection` or `CommitedMain`. 26 | - `MainConnection` - connection to the main database has been established and used to do 27 | writes to the database. `DualConnection` will keep using it until closed. 28 | The main reasons not to switch to replica from this state: 29 | - to avoid affecting transactions 30 | - to avoid affecting locks 31 | - `CommitedMain` - connection to the main database has been established, but only 32 | for reads. The replica was temporarily inconsistent. This state shares some features 33 | of both `ReplicaConnection` and `MainConnection` states. It's connected to the main database, 34 | but can switch to the replica. 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/dual-connection-states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/dual-connection-states.png -------------------------------------------------------------------------------- /docs/dual-connection-states.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | state isWriteContext <> 3 | state isWriteQuery <> 4 | state isReplicaConsistent <> 5 | 6 | 7 | NoConnection ---> isWriteContext : **Initial query**\n\nis write context? 8 | isWriteContext ---> MainConnection : Yes 9 | 10 | isWriteContext --> isWriteQuery : No\n\nis write query? 11 | isWriteQuery ---> MainConnection : Yes 12 | isWriteQuery ---> isReplicaConsistent: No\n\nis replica consistent? 13 | 14 | isReplicaConsistent ---> ReplicaConnection : Yes 15 | isReplicaConsistent ---> CommitedMain : No 16 | CommitedMain ---> isWriteContext : **Next query**\n\nis write context? 17 | 18 | MainConnection ---> MainConnection : **Next query** 19 | ReplicaConnection ---> isWriteContext : **Next query**\n\nis write context? 20 | 21 | NoConnection ---> ClosedConnection 22 | MainConnection ---> ClosedConnection 23 | ReplicaConnection ---> ClosedConnection 24 | 25 | @enduml 26 | -------------------------------------------------------------------------------- /docs/high-level-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/high-level-overview.png -------------------------------------------------------------------------------- /docs/high-level-overview.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | database "Main database" as main 4 | database "Replica database 1" as replica 5 | database "Replica database 2" as replica2 6 | database "Replica database n" as replica3 7 | 8 | node "Application" { 9 | package Connection { 10 | package DualConnection { 11 | [ConnectionProvider] 12 | } 13 | } 14 | [Business logic] --> Connection : database call 15 | [ConnectionProvider] --> [Main Connection] : provides 16 | [ConnectionProvider] --> [Replica Connection] : provides 17 | } 18 | 19 | [Main Connection] --> main 20 | [Replica Connection] --> replica 21 | [Replica Connection] --> replica2 22 | [Replica Connection] --> replica3 23 | 24 | 25 | 26 | 27 | @enduml 28 | -------------------------------------------------------------------------------- /docs/release/releasing.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 1. Release and publish a new version of library: 3 | * It can be [triggered in GitHub Actions]. 4 | * If you want to debug it, you can run it locally as per [`gradle-release` plugin] docs. 5 | 2. Update the repo to the new version (via pull request): 6 | * Cut off the `Unreleased` section in the [changelog] with the new version. 7 | * Update the version in the [readme] installation section. 8 | * Clean up internal `compatibleWithPreviousVersion` usages if they exist. 9 | 10 | [changelog]: ../../CHANGELOG.md 11 | [readme]: ../../README.md 12 | [`gradle-release` plugin]: https://bitbucket.org/atlassian/gradle-release/src/master/README.md 13 | [triggered in GitHub Actions]: trigger-gha-release.mp4 14 | -------------------------------------------------------------------------------- /docs/release/trigger-gha-release.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/release/trigger-gha-release.mp4 -------------------------------------------------------------------------------- /docs/split-instrumentation.md: -------------------------------------------------------------------------------- 1 | ## Configurable main/replica split instrumentation 2 | 3 | All calls to the database goes through [DatabaseCall](../src/main/java/com/atlassian/db/replica/spi/DatabaseCall.java). 4 | By default [DualConnection](../src/main/java/com/atlassian/db/replica/api/DualConnection.java) forwards all the calls. 5 | It can be used to log queries, gather metrics or to handle exceptions. 6 | 7 | ![Split](split-instrumentation.png "SplitInstrumentation") 8 | 9 | Every database operation on database will go through `DatabaseCall#call`. 10 | -------------------------------------------------------------------------------- /docs/split-instrumentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/docs/split-instrumentation.png -------------------------------------------------------------------------------- /docs/split-instrumentation.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | @startuml 4 | 5 | class DualConnection 6 | 7 | interface DatabaseCall{ 8 | T call( SqlCall call, RouteDecision routeDecision) 9 | } 10 | 11 | DualConnection *-- DatabaseCall 12 | DualConnection --> DatabaseCall::call : db operation 13 | 14 | 15 | @enduml 16 | 17 | 18 | @enduml 19 | -------------------------------------------------------------------------------- /docs/switching-between-main-and-replica.md: -------------------------------------------------------------------------------- 1 | ## How DualConnection chooses between main and replica? 2 | 3 | [DualConnection](../src/main/java/com/atlassian/db/replica/api/DualConnection.java) takes multiple aspects while deciding 4 | which connection to use: 5 | 6 | 1. A [connection's state](dual-connection-states.md). 7 | 2. A replica's [consistency](consistency.md). 8 | 3. Context of `java.sql.Connection`/`java.sql.Statement` API usage. 9 | 10 | Some of the methods are intended to write into the database. For example every call to `java.sq.PreparedStatement#executeUpdate` 11 | will switch the connection's state to the main database. 12 | 13 | 4. Availability of replica 14 | 5. The query will use the main database in case it's: 15 | - `SELECT FOR UPDATE` statement. 16 | - `UPDATE` statement. 17 | - `DELETE` statement. 18 | - All calls with transaction isolation level higher than `TRANSACTION_READ_COMMITTED`. 19 | 20 | 6. The query will use the main database in case it's an unknown function call. Known read-only functions are [standard 21 | SQL functions](https://www.postgresql.org/docs/9.4/functions.html) and user defined functions. 22 | -------------------------------------------------------------------------------- /docs/uml.md: -------------------------------------------------------------------------------- 1 | ## UML 2 | 3 | 4 | [UML](https://en.wikipedia.org/wiki/Unified_Modeling_Language) is a structured visual way of explaining a system. 5 | 6 | It can be used to: 7 | 8 | - document how db-replica works 9 | 10 | - suggest how db-replica could or will work 11 | 12 | - explain complex scenarios 13 | 14 | 15 | ### Sequence diagram 16 | 17 | 18 | An [UML sequence diagram](https://en.wikipedia.org/wiki/Sequence_diagram) shows time passing top-down. 19 | 20 | Participants are exchanging messages between each other at different points in time. 21 | 22 | 23 | ### PlantUML 24 | 25 | 26 | [PlantUML](https://plantuml.com/sitemap-language-specification) is a library, which turns text syntax into UML images. 27 | 28 | 29 | There's an [IntelliJ plugin](https://plugins.jetbrains.com/plugin/7017-plantuml-integration). 30 | 31 | 32 | Here's a few online editors: 33 | 34 | - [LiveUML](https://liveuml.com/) 35 | 36 | - [PlantUML Server](http://www.plantuml.com/plantuml/) 37 | 38 | - [PlantText](https://www.planttext.com/) 39 | -------------------------------------------------------------------------------- /gradle/dependency-locks/annotationProcessor.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | -------------------------------------------------------------------------------- /gradle/dependency-locks/compileClasspath.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | -------------------------------------------------------------------------------- /gradle/dependency-locks/runtimeClasspath.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | -------------------------------------------------------------------------------- /gradle/dependency-locks/signatures.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | -------------------------------------------------------------------------------- /gradle/dependency-locks/testAnnotationProcessor.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | -------------------------------------------------------------------------------- /gradle/dependency-locks/testCompileClasspath.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | com.fasterxml.jackson.core:jackson-annotations:2.10.3 5 | com.fasterxml.jackson.core:jackson-core:2.10.3 6 | com.fasterxml.jackson.core:jackson-databind:2.10.3 7 | com.github.docker-java:docker-java-api:3.2.6 8 | com.github.docker-java:docker-java-core:3.2.6 9 | com.github.docker-java:docker-java-transport-httpclient5:3.2.6 10 | com.github.docker-java:docker-java-transport:3.2.6 11 | com.google.guava:guava:19.0 12 | com.h2database:h2:1.4.200 13 | commons-codec:commons-codec:1.13 14 | commons-io:commons-io:2.6 15 | commons-lang:commons-lang:2.6 16 | net.bytebuddy:byte-buddy-agent:1.11.1 17 | net.bytebuddy:byte-buddy:1.11.1 18 | net.java.dev.jna:jna:5.5.0 19 | org.apache.commons:commons-compress:1.20 20 | org.apache.httpcomponents.client5:httpclient5:5.0 21 | org.apache.httpcomponents.core5:httpcore5:5.0 22 | org.apiguardian:apiguardian-api:1.1.0 23 | org.assertj:assertj-core:3.19.0 24 | org.bouncycastle:bcpkix-jdk15on:1.64 25 | org.bouncycastle:bcprov-jdk15on:1.64 26 | org.junit.jupiter:junit-jupiter-api:5.7.2 27 | org.junit.jupiter:junit-jupiter-params:5.7.2 28 | org.junit.jupiter:junit-jupiter:5.7.2 29 | org.junit.platform:junit-platform-commons:1.7.2 30 | org.junit:junit-bom:5.7.2 31 | org.mockito:mockito-core:3.11.0 32 | org.mockito:mockito-junit-jupiter:3.11.0 33 | org.objenesis:objenesis:3.2 34 | org.opentest4j:opentest4j:1.2.0 35 | org.postgresql:postgresql:42.2.18 36 | org.slf4j:slf4j-api:1.7.30 37 | org.threeten:threeten-extra:1.5.0 38 | -------------------------------------------------------------------------------- /gradle/dependency-locks/testRuntimeClasspath.lockfile: -------------------------------------------------------------------------------- 1 | # This is a Gradle generated file for dependency locking. 2 | # Manual edits can break the build and are not advised. 3 | # This file is expected to be part of source control. 4 | com.fasterxml.jackson.core:jackson-annotations:2.10.3 5 | com.fasterxml.jackson.core:jackson-core:2.10.3 6 | com.fasterxml.jackson.core:jackson-databind:2.10.3 7 | com.github.docker-java:docker-java-api:3.2.6 8 | com.github.docker-java:docker-java-core:3.2.6 9 | com.github.docker-java:docker-java-transport-httpclient5:3.2.6 10 | com.github.docker-java:docker-java-transport:3.2.6 11 | com.google.guava:guava:19.0 12 | com.h2database:h2:1.4.200 13 | commons-codec:commons-codec:1.13 14 | commons-io:commons-io:2.6 15 | commons-lang:commons-lang:2.6 16 | net.bytebuddy:byte-buddy-agent:1.11.1 17 | net.bytebuddy:byte-buddy:1.11.1 18 | net.java.dev.jna:jna:5.5.0 19 | org.apache.commons:commons-compress:1.20 20 | org.apache.httpcomponents.client5:httpclient5:5.0 21 | org.apache.httpcomponents.core5:httpcore5:5.0 22 | org.apiguardian:apiguardian-api:1.1.0 23 | org.assertj:assertj-core:3.19.0 24 | org.bouncycastle:bcpkix-jdk15on:1.64 25 | org.bouncycastle:bcprov-jdk15on:1.64 26 | org.checkerframework:checker-qual:3.5.0 27 | org.junit.jupiter:junit-jupiter-api:5.7.2 28 | org.junit.jupiter:junit-jupiter-engine:5.7.2 29 | org.junit.jupiter:junit-jupiter-params:5.7.2 30 | org.junit.jupiter:junit-jupiter:5.7.2 31 | org.junit.platform:junit-platform-commons:1.7.2 32 | org.junit.platform:junit-platform-engine:1.7.2 33 | org.junit:junit-bom:5.7.2 34 | org.mockito:mockito-core:3.11.0 35 | org.mockito:mockito-junit-jupiter:3.11.0 36 | org.objenesis:objenesis:3.2 37 | org.opentest4j:opentest4j:1.2.0 38 | org.postgresql:postgresql:42.2.18 39 | org.slf4j:slf4j-api:1.7.30 40 | org.threeten:threeten-extra:1.5.0 41 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atlassian-labs/db-replica/096dcc0493ed7c0aafc5de550a9f18a99a11e96e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "db-replica" 2 | 3 | pluginManagement { 4 | repositories { 5 | gradlePluginPortal() // work around artifactory-sidekick https://jdog.jira-dev.com/browse/JCES-1751 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/AuroraConnectionDetails.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import com.atlassian.db.replica.spi.ReplicaConnectionPerUrlProvider; 4 | 5 | import java.sql.DriverManager; 6 | 7 | /** 8 | * @deprecated use {@link ReplicaConnectionPerUrlProvider} instead. example usage: 9 | *
10 |  *     {@code
11 |  *          url -> () -> DriverManager.getConnection(
12 |  *              url.toString(),
13 |  *              username,
14 |  *              password
15 |  *          )
16 |  *     }
17 |  * 
18 | */ 19 | @Deprecated 20 | public final class AuroraConnectionDetails { 21 | private final String username; 22 | private final String password; 23 | 24 | private AuroraConnectionDetails(String username, String password) { 25 | this.username = username; 26 | this.password = password; 27 | } 28 | 29 | public static Builder builder() { 30 | return new Builder(); 31 | } 32 | 33 | public String getUsername() { 34 | return this.username; 35 | } 36 | 37 | public String getPassword() { 38 | return this.password; 39 | } 40 | 41 | public ReplicaConnectionPerUrlProvider convert() { 42 | return replicaUrl -> () -> DriverManager.getConnection( 43 | replicaUrl.toString(), 44 | username, 45 | password 46 | ); 47 | } 48 | 49 | /** 50 | * @deprecated see {@link AuroraConnectionDetails}. 51 | */ 52 | @Deprecated 53 | public static final class Builder { 54 | private String username; 55 | private String password; 56 | 57 | public Builder username(String username) { 58 | this.username = username; 59 | return this; 60 | } 61 | 62 | public Builder password(String password) { 63 | this.password = password; 64 | return this; 65 | } 66 | 67 | /** 68 | * @deprecated see {@link AuroraConnectionDetails}. 69 | */ 70 | @Deprecated 71 | public static Builder anAuroraConnectionDetailsBuilder() { 72 | return new Builder(); 73 | } 74 | 75 | public AuroraConnectionDetails build() { 76 | return new AuroraConnectionDetails(username, password); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/AuroraPostgresLsnReplicaConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import com.atlassian.db.replica.internal.MonotonicMemoryCache; 4 | import com.atlassian.db.replica.internal.NoCacheSuppliedCache; 5 | import com.atlassian.db.replica.spi.Cache; 6 | import com.atlassian.db.replica.spi.ReplicaConsistency; 7 | import com.atlassian.db.replica.spi.SuppliedCache; 8 | 9 | import java.sql.Connection; 10 | import java.sql.PreparedStatement; 11 | import java.sql.ResultSet; 12 | import java.sql.SQLException; 13 | import java.util.Optional; 14 | import java.util.function.Supplier; 15 | 16 | /** 17 | * It remembers last write LSN (log sequence number) and compares it with LSN for subsequent reads to detect inconsistencies. 18 | * If it cannot remember the LSN of last write, pessimistically assumes it's going to be inconsistent. 19 | * 20 | * @see Monitoring Aurora PostgreSQL-based Aurora global databases 21 | *

22 | * It's an PostgreSQL-based Aurora database engine specific implementation. 23 | */ 24 | public final class AuroraPostgresLsnReplicaConsistency implements ReplicaConsistency { 25 | 26 | private final Cache lastWrite; 27 | private final SuppliedCache replicaLsnCache; 28 | 29 | public static final class Builder { 30 | private Cache lastWrite = new MonotonicMemoryCache<>(); 31 | private SuppliedCache replicaLsnCache = new NoCacheSuppliedCache<>(); 32 | 33 | /** 34 | * @param lastWrite remembers last write 35 | */ 36 | public Builder cacheLastWrite(Cache lastWrite) { 37 | this.lastWrite = lastWrite; 38 | return this; 39 | } 40 | 41 | /** 42 | * @param cache remembers replica lsn (potentially stale) 43 | */ 44 | public Builder replicaLsnCache(SuppliedCache cache) { 45 | this.replicaLsnCache = cache; 46 | return this; 47 | } 48 | 49 | /** 50 | * @return consistency assuming that LSN (log sequence number) is greater or equal to LSN for last write (if known) 51 | */ 52 | public AuroraPostgresLsnReplicaConsistency build() { 53 | return new AuroraPostgresLsnReplicaConsistency(lastWrite, replicaLsnCache); 54 | } 55 | } 56 | 57 | private AuroraPostgresLsnReplicaConsistency( 58 | Cache lastWrite, 59 | SuppliedCache replicaLsnCache 60 | ) { 61 | this.lastWrite = lastWrite; 62 | this.replicaLsnCache = replicaLsnCache; 63 | } 64 | 65 | @Override 66 | public void write(Connection main) { 67 | try { 68 | lastWrite.put(queryMainDbLsn(main)); 69 | } catch (Exception e) { 70 | throw new RuntimeException("failure during LSN fetching for main database", e); 71 | } 72 | } 73 | 74 | @Override 75 | public boolean isConsistent(Supplier replica) { 76 | try { 77 | return lastWrite.get() 78 | .flatMap(lastWriteLsn -> isConsistentBasedOnLsn(replica, lastWriteLsn)) 79 | .orElse(false); 80 | } catch (Exception e) { 81 | return false; 82 | } 83 | } 84 | 85 | private Optional isConsistentBasedOnLsn(Supplier replica, Long lastWriteLsn) { 86 | return replicaLsnCache 87 | .get(() -> queryReplicaDbLsn(replica.get())) 88 | .map(lsn -> lsn >= lastWriteLsn); 89 | } 90 | 91 | /** 92 | * @return LSN (log sequence number) for the main database (the highest LSN) 93 | */ 94 | private long queryMainDbLsn(Connection connection) { 95 | return queryLsn(connection, "SELECT MAX(durable_lsn) AS lsn FROM aurora_global_db_instance_status();"); 96 | } 97 | 98 | /** 99 | * @return LSN (log sequence number) for the most outdated replica database (the lowest LSN) 100 | */ 101 | private long queryReplicaDbLsn(Connection connection) { 102 | return queryLsn(connection, "SELECT MIN(durable_lsn) AS lsn FROM aurora_global_db_instance_status();"); 103 | } 104 | 105 | private long queryLsn(Connection connection, String rawSqlQuery) { 106 | try ( 107 | PreparedStatement query = connection.prepareStatement(rawSqlQuery); 108 | ResultSet results = query.executeQuery() 109 | ) { 110 | results.next(); 111 | return results.getLong("lsn"); 112 | } catch (SQLException e) { 113 | throw new RuntimeException("An SQLException occurred during LSN fetching", e); 114 | } 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/Database.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import java.sql.Connection; 4 | import java.util.function.Supplier; 5 | 6 | public interface Database { 7 | String getId(); 8 | 9 | Supplier getConnectionSupplier(); 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/PessimisticPropagationConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import com.atlassian.db.replica.internal.MonotonicMemoryCache; 4 | import com.atlassian.db.replica.spi.Cache; 5 | import com.atlassian.db.replica.spi.ReplicaConsistency; 6 | 7 | import java.sql.Connection; 8 | import java.time.Clock; 9 | import java.time.Duration; 10 | import java.time.Instant; 11 | import java.util.function.Supplier; 12 | 13 | /** 14 | * Assumes that writes propagate from main to replicas in at most a given amount of time. 15 | * If it cannot remember the time of last write, pessimistically assumes it's going to be inconsistent. 16 | */ 17 | public final class PessimisticPropagationConsistency implements ReplicaConsistency { 18 | 19 | private final Clock clock; 20 | private final Duration maxPropagation; 21 | private final Cache lastWrite; 22 | 23 | public static class Builder { 24 | private Duration maxPropagation = Duration.ofMillis(100); 25 | private Cache lastWrite = new MonotonicMemoryCache<>(); 26 | private Clock clock = Clock.systemUTC(); 27 | 28 | /** 29 | * @param maxPropagation how long do writes propagate from main to replica 30 | */ 31 | public Builder assumeMaxPropagation(Duration maxPropagation) { 32 | this.maxPropagation = maxPropagation; 33 | return this; 34 | } 35 | 36 | /** 37 | * @param lastWrite remembers last write 38 | */ 39 | public Builder cacheLastWrite(Cache lastWrite) { 40 | this.lastWrite = lastWrite; 41 | return this; 42 | } 43 | 44 | /** 45 | * @param clock measures flow of time 46 | */ 47 | public Builder measureTime(Clock clock) { 48 | this.clock = clock; 49 | return this; 50 | } 51 | 52 | /** 53 | * @return consistency assuming consistency after max propagation since last write (if known) 54 | */ 55 | public ReplicaConsistency build() { 56 | return new PessimisticPropagationConsistency(clock, maxPropagation, lastWrite); 57 | } 58 | } 59 | 60 | private PessimisticPropagationConsistency(Clock clock, Duration maxPropagation, Cache lastWrite) { 61 | this.clock = clock; 62 | this.maxPropagation = maxPropagation; 63 | this.lastWrite = lastWrite; 64 | } 65 | 66 | @Override 67 | public void write(Connection main) { 68 | lastWrite.put(clock.instant()); 69 | } 70 | 71 | @Override 72 | public boolean isConsistent(Supplier replica) { 73 | Instant assumedRefresh = assumeLastRefresh(); 74 | Instant assumedWrite = assumeLastWrite(); 75 | return assumedRefresh.isAfter(assumedWrite); 76 | } 77 | 78 | /** 79 | * @return assumed time of last replica refresh 80 | */ 81 | private Instant assumeLastRefresh() { 82 | return clock.instant().minus(maxPropagation); 83 | } 84 | 85 | /** 86 | * If {@code lastWrite} is unknown, assume the write just happened, e.g. it didn't propagate yet. 87 | * This assumption errs on the side of caution: more true inconsistencies at the cost of fewer true consistencies. 88 | *

89 | * Propagates write assumption to the cache. This prevents from assuming write just happened until the next write. 90 | * 91 | * @return known or assumed time of last write 92 | */ 93 | private Instant assumeLastWrite() { 94 | return lastWrite 95 | .get() 96 | .orElseGet(this::assumeWriteJustHappened); 97 | } 98 | 99 | private Instant assumeWriteJustHappened() { 100 | final Instant now = clock.instant(); 101 | lastWrite.put(now); 102 | return now; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/SqlCall.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import java.sql.*; 4 | 5 | /** 6 | * Like a {@link java.util.concurrent.Callable}, but with a checked exception. 7 | * @param 8 | */ 9 | public interface SqlCall { 10 | T call() throws SQLException; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/exception/ConnectionCouldNotBeClosedException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.exception; 2 | 3 | public final class ConnectionCouldNotBeClosedException extends RuntimeException { 4 | public ConnectionCouldNotBeClosedException(Throwable cause) { 5 | super(cause); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/exception/ReadReplicaConnectionCreationException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.exception; 2 | 3 | public class ReadReplicaConnectionCreationException extends RuntimeException { 4 | 5 | public ReadReplicaConnectionCreationException(Throwable cause) { 6 | super("Failure during replica connection creation", cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/jdbc/JdbcProtocol.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.jdbc; 2 | 3 | import java.util.Objects; 4 | 5 | public final class JdbcProtocol { 6 | public static final JdbcProtocol POSTGRES = new JdbcProtocol("postgresql"); 7 | 8 | private final String protocol; 9 | 10 | private JdbcProtocol(String protocol) { 11 | this.protocol = protocol; 12 | } 13 | 14 | @Override 15 | public boolean equals(Object o) { 16 | if (this == o) return true; 17 | if (o == null || getClass() != o.getClass()) return false; 18 | JdbcProtocol that = (JdbcProtocol) o; 19 | return Objects.equals(protocol, that.protocol); 20 | } 21 | 22 | @Override 23 | public int hashCode() { 24 | return Objects.hash(protocol); 25 | } 26 | 27 | @Override 28 | public String toString() { 29 | return protocol; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/jdbc/JdbcUrl.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.jdbc; 2 | 3 | import java.net.URI; 4 | 5 | public final class JdbcUrl { 6 | private static final String PREFIX = "jdbc"; 7 | 8 | private final String internalUrl; 9 | 10 | private JdbcUrl(JdbcProtocol protocol, String endpoint, String database) { 11 | String url = String.format("%s:%s://%s/%s", PREFIX, protocol, endpoint, database); 12 | this.internalUrl = URI.create(url).toString(); 13 | } 14 | 15 | public static Builder builder() { 16 | return new Builder(); 17 | } 18 | 19 | @Override 20 | public String toString() { 21 | return this.internalUrl; 22 | } 23 | 24 | public static final class Builder { 25 | private JdbcProtocol protocol; 26 | private String endpoint; 27 | private String database; 28 | 29 | private Builder() { 30 | } 31 | 32 | public Builder protocol(JdbcProtocol protocol) { 33 | this.protocol = protocol; 34 | return this; 35 | } 36 | 37 | public Builder endpoint(String endpoint) { 38 | this.endpoint = endpoint; 39 | return this; 40 | } 41 | 42 | public Builder database(String database) { 43 | this.database = database; 44 | return this; 45 | } 46 | 47 | public JdbcUrl build() { 48 | return new JdbcUrl(protocol, endpoint, database); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/reason/Reason.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.reason; 2 | 3 | import java.util.Objects; 4 | 5 | /** 6 | * Describes the reason to choose either replica or main database connection. 7 | */ 8 | public final class Reason { 9 | private final String name; 10 | private final boolean isRunOnMain; 11 | private final boolean isWrite; 12 | 13 | private Reason(final String name, boolean isRunOnMain, boolean isWrite) { 14 | this.name = name; 15 | this.isRunOnMain = isRunOnMain; 16 | this.isWrite = isWrite; 17 | } 18 | 19 | public static final Reason RW_API_CALL = 20 | new ReasonBuilder("RW_API_CALL").isRunOnMain(true).isWrite(true).build(); 21 | public static final Reason REPLICA_INCONSISTENT = 22 | new ReasonBuilder("REPLICA_INCONSISTENT").isRunOnMain(true).isWrite(false).build(); 23 | public static final Reason READ_OPERATION = 24 | new ReasonBuilder("READ_OPERATION").isRunOnMain(false).isWrite(false).build(); 25 | public static final Reason WRITE_OPERATION = 26 | new ReasonBuilder("WRITE_OPERATION").isRunOnMain(true).isWrite(true).build(); 27 | public static final Reason LOCK = 28 | new ReasonBuilder("LOCK").isRunOnMain(true).isWrite(false).build(); 29 | public static final Reason MAIN_CONNECTION_REUSE = 30 | new ReasonBuilder("MAIN_CONNECTION_REUSE").isRunOnMain(true).isWrite(false).build(); 31 | public static final Reason HIGH_TRANSACTION_ISOLATION_LEVEL = 32 | new ReasonBuilder("HIGH_TRANSACTION_ISOLATION_LEVEL").isRunOnMain(true).isWrite(false).build(); 33 | public static final Reason RO_API_CALL = 34 | new ReasonBuilder("RO_API_CALL").isRunOnMain(false).isWrite(false).build(); 35 | 36 | public String getName() { 37 | return name; 38 | } 39 | 40 | boolean isRunOnMain() { 41 | return isRunOnMain; 42 | } 43 | 44 | boolean isWrite() { 45 | return isWrite; 46 | } 47 | 48 | @Override 49 | public boolean equals(Object o) { 50 | if (this == o) return true; 51 | if (o == null || getClass() != o.getClass()) return false; 52 | Reason reason = (Reason) o; 53 | return isRunOnMain == reason.isRunOnMain && isWrite == reason.isWrite && Objects.equals(name, reason.name); 54 | } 55 | 56 | @Override 57 | public int hashCode() { 58 | return Objects.hash(name, isRunOnMain); 59 | } 60 | 61 | @Override 62 | public String toString() { 63 | return "Reason{" + 64 | "name='" + name + '\'' + 65 | ", isRunOnMain=" + isRunOnMain + 66 | ", isWrite=" + isWrite + 67 | '}'; 68 | } 69 | 70 | private static class ReasonBuilder { 71 | private final String name; 72 | private boolean isRunOnMain = false; 73 | private boolean isWrite = false; 74 | 75 | ReasonBuilder(final String name) { 76 | this.name = name; 77 | } 78 | 79 | ReasonBuilder isRunOnMain(boolean isRunOnMain) { 80 | this.isRunOnMain = isRunOnMain; 81 | return this; 82 | } 83 | 84 | ReasonBuilder isWrite(boolean isWrite) { 85 | this.isWrite = isWrite; 86 | return this; 87 | } 88 | 89 | Reason build() { 90 | return new Reason(name, isRunOnMain, isWrite); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/api/reason/RouteDecision.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.reason; 2 | 3 | import java.util.Objects; 4 | import java.util.Optional; 5 | 6 | /** 7 | * Reveals details related to why, and which database will be used. 8 | */ 9 | public final class RouteDecision { 10 | private final Reason reason; 11 | private final String sql; 12 | private final RouteDecision cause; 13 | 14 | public RouteDecision(String sql, Reason reason, RouteDecision cause) { 15 | this.sql = sql; 16 | this.reason = reason; 17 | this.cause = cause; 18 | } 19 | 20 | /** 21 | * @return Reason for the current route. The state of the connection may enforce it. 22 | */ 23 | public Reason getReason() { 24 | return reason; 25 | } 26 | 27 | /** 28 | * @return An SQL corresponding to the current route, if any. 29 | */ 30 | public Optional getSql() { 31 | return Optional.ofNullable(sql); 32 | } 33 | 34 | /** 35 | * @return The initial decision to change the state, if any. 36 | */ 37 | public Optional getCause() { 38 | return Optional.ofNullable(cause); 39 | } 40 | 41 | /** 42 | * @return true if the accompanying {@link com.atlassian.db.replica.api.SqlCall#call()} would fail when run on replica. 43 | */ 44 | public boolean mustRunOnMain() { 45 | return reason.isWrite(); 46 | } 47 | 48 | /** 49 | * @return true if the accompanying {@link com.atlassian.db.replica.api.SqlCall#call()} will be run on the main database. 50 | */ 51 | public boolean willRunOnMain() { 52 | return reason.isRunOnMain(); 53 | } 54 | 55 | @Override 56 | public boolean equals(Object o) { 57 | if (this == o) return true; 58 | if (o == null || getClass() != o.getClass()) return false; 59 | RouteDecision that = (RouteDecision) o; 60 | return Objects.equals(reason, that.reason) && Objects.equals( 61 | sql, 62 | that.sql 63 | ) && Objects.equals(cause, that.cause); 64 | } 65 | 66 | @Override 67 | public int hashCode() { 68 | return Objects.hash(reason, sql, cause); 69 | } 70 | 71 | @Override 72 | public String toString() { 73 | return "RouteDecision{" + 74 | "reason=" + reason + 75 | ", sql='" + sql + '\'' + 76 | ", cause=" + cause + 77 | '}'; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/ClientInfo.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLClientInfoException; 5 | import java.util.Objects; 6 | import java.util.Properties; 7 | 8 | public final class ClientInfo { 9 | private final String name; 10 | private final String value; 11 | private final Properties properties; 12 | 13 | public ClientInfo(String value, String name) { 14 | this.value = value; 15 | this.name = name; 16 | this.properties = null; 17 | } 18 | 19 | public ClientInfo(Properties properties) { 20 | this.properties = properties; 21 | this.name = null; 22 | this.value = null; 23 | } 24 | 25 | public void configure(Connection connection) throws SQLClientInfoException { 26 | if (properties != null) { 27 | connection.setClientInfo(properties); 28 | } else { 29 | connection.setClientInfo(name, value); 30 | } 31 | } 32 | 33 | @Override 34 | public boolean equals(Object o) { 35 | if (this == o) return true; 36 | if (o == null || getClass() != o.getClass()) return false; 37 | ClientInfo that = (ClientInfo) o; 38 | return Objects.equals(name, that.name) && Objects.equals(value, that.value) && Objects.equals( 39 | properties, 40 | that.properties 41 | ); 42 | } 43 | 44 | @Override 45 | public int hashCode() { 46 | return Objects.hash(name, value, properties); 47 | } 48 | 49 | @Override 50 | public String toString() { 51 | return "ClientInfo{" + 52 | "name='" + name + '\'' + 53 | ", value='" + value + '\'' + 54 | ", properties=" + properties + 55 | '}'; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/ConnectionOperation.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | 4 | import java.sql.Connection; 5 | import java.sql.SQLException; 6 | 7 | public interface ConnectionOperation { 8 | void accept(Connection connection) throws SQLException; 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/DecisionAwareReference.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import java.util.concurrent.atomic.AtomicReference; 4 | 5 | public abstract class DecisionAwareReference extends LazyReference { 6 | private final AtomicReference firstCause = new AtomicReference<>(); 7 | 8 | protected DecisionAwareReference() { 9 | super(); 10 | } 11 | 12 | public T get(RouteDecisionBuilder currentCause) { 13 | firstCause.compareAndSet(null, currentCause); 14 | return super.get(); 15 | } 16 | 17 | @Override 18 | public void reset() { 19 | super.reset(); 20 | firstCause.set(null); 21 | } 22 | 23 | public RouteDecisionBuilder getFirstCause() { 24 | if (firstCause.get() == null) { 25 | throw new IllegalStateException("The decision builder is not initialized"); 26 | } 27 | return firstCause.get(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/ForwardCall.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | import com.atlassian.db.replica.api.reason.RouteDecision; 5 | import com.atlassian.db.replica.spi.DatabaseCall; 6 | 7 | import java.sql.SQLException; 8 | 9 | public class ForwardCall implements DatabaseCall { 10 | 11 | @Override 12 | public T call(final SqlCall call, RouteDecision decision) throws SQLException { 13 | return call.call(); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/LazyReference.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | 4 | import com.atlassian.db.replica.internal.util.ThreadSafe; 5 | 6 | import java.util.function.Supplier; 7 | 8 | @ThreadSafe 9 | public abstract class LazyReference implements Supplier { 10 | private T reference; 11 | private final Object lock = new Object(); 12 | 13 | protected LazyReference() { 14 | } 15 | 16 | protected abstract T create() throws Exception; 17 | 18 | public boolean isInitialized() { 19 | return reference != null; 20 | } 21 | 22 | @Override 23 | public T get() { 24 | synchronized (lock) { 25 | if (!isInitialized()) { 26 | try { 27 | reference = create(); 28 | } catch (Exception e) { 29 | throw new RuntimeException(e); 30 | } 31 | } 32 | } 33 | return reference; 34 | } 35 | 36 | public void reset() { 37 | reference = null; 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/LockBasedThrottledCache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.internal.util.ThreadSafe; 4 | import com.atlassian.db.replica.spi.SuppliedCache; 5 | 6 | import java.util.Optional; 7 | import java.util.concurrent.locks.ReentrantLock; 8 | import java.util.function.Supplier; 9 | 10 | @ThreadSafe 11 | public final class LockBasedThrottledCache implements SuppliedCache { 12 | private final ReentrantLock lock = new ReentrantLock(); 13 | private T value = null; 14 | 15 | @Override 16 | public Optional get(Supplier supplier) { 17 | maybeRefresh(supplier); 18 | return Optional.ofNullable(value); 19 | } 20 | 21 | @Override 22 | public Optional get() { 23 | return Optional.ofNullable(value); 24 | } 25 | 26 | private void maybeRefresh(Supplier supplier) { 27 | if(lock.tryLock()) { 28 | try { 29 | value = supplier.get(); 30 | } finally { 31 | lock.unlock(); 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/MonotonicMemoryCache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.spi.*; 4 | 5 | import java.util.*; 6 | import java.util.concurrent.atomic.*; 7 | 8 | import static com.atlassian.db.replica.internal.util.Comparables.*; 9 | 10 | /** 11 | * Holds values that grow over time, unless reset. Holds a value in JVM memory. 12 | * 13 | * @param 14 | */ 15 | public class MonotonicMemoryCache> implements Cache { 16 | 17 | private final AtomicReference cache = new AtomicReference<>(null); 18 | 19 | @Override 20 | public Optional get() { 21 | return Optional.ofNullable(cache.get()); 22 | } 23 | 24 | @Override 25 | public void put(T value) { 26 | cache.updateAndGet(prev -> max(prev, value)); 27 | } 28 | 29 | @Override 30 | public void reset() { 31 | cache.set(null); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/NetworkTimeout.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | import java.util.Objects; 6 | import java.util.concurrent.Executor; 7 | 8 | public final class NetworkTimeout { 9 | private final Executor executor; 10 | private final int milliseconds; 11 | 12 | public NetworkTimeout(Executor executor, int milliseconds) { 13 | this.executor = executor; 14 | this.milliseconds = milliseconds; 15 | } 16 | 17 | public void configure(Connection connection) throws SQLException { 18 | connection.setNetworkTimeout(executor, milliseconds); 19 | } 20 | 21 | @Override 22 | public boolean equals(Object o) { 23 | if (this == o) return true; 24 | if (o == null || getClass() != o.getClass()) return false; 25 | NetworkTimeout that = (NetworkTimeout) o; 26 | return milliseconds == that.milliseconds && Objects.equals(executor, that.executor); 27 | } 28 | 29 | @Override 30 | public int hashCode() { 31 | return Objects.hash(executor, milliseconds); 32 | } 33 | 34 | @Override 35 | public String toString() { 36 | return "NetworkTimeout{" + 37 | "executor=" + executor + 38 | ", milliseconds=" + milliseconds + 39 | '}'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/NoCacheSuppliedCache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.spi.SuppliedCache; 4 | 5 | import java.util.Optional; 6 | import java.util.function.Supplier; 7 | 8 | /** 9 | * No cache implementation 10 | */ 11 | public final class NoCacheSuppliedCache implements SuppliedCache { 12 | @Override 13 | public Optional get(Supplier supplier) { 14 | return Optional.ofNullable(supplier.get()); 15 | } 16 | 17 | @Override 18 | public Optional get() { 19 | return Optional.empty(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/NoOpDirtyConnectionCloseHook.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.spi.DirtyConnectionCloseHook; 4 | 5 | import java.sql.Connection; 6 | 7 | public class NoOpDirtyConnectionCloseHook implements DirtyConnectionCloseHook { 8 | @Override 9 | public void onClose(Connection connection) { 10 | // do nothing 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/NotLoggingLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.spi.Logger; 4 | 5 | public class NotLoggingLogger implements Logger { 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/ReadReplicaUnsupportedOperationException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | /** 4 | * I just want to have a single place to keep debugger aware of calls to unsupported operations. 5 | * I plan to remove it once all the calls supported. 6 | */ 7 | public class ReadReplicaUnsupportedOperationException extends RuntimeException { 8 | public ReadReplicaUnsupportedOperationException() { 9 | super(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/RouteDecisionBuilder.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.api.reason.Reason; 4 | import com.atlassian.db.replica.api.reason.RouteDecision; 5 | 6 | import java.util.Objects; 7 | 8 | public final class RouteDecisionBuilder { 9 | private String sql = null; 10 | private Reason reason; 11 | private RouteDecision cause = null; 12 | 13 | public RouteDecisionBuilder(Reason reason) { 14 | this.reason = reason; 15 | } 16 | 17 | public RouteDecisionBuilder sql(final String sql) { 18 | this.sql = sql; 19 | return this; 20 | } 21 | 22 | public RouteDecisionBuilder reason(final Reason reason) { 23 | this.reason = reason; 24 | return this; 25 | } 26 | 27 | public RouteDecisionBuilder cause(final RouteDecision cause) { 28 | this.cause = cause; 29 | return this; 30 | } 31 | 32 | public String getSql() { 33 | return sql; 34 | } 35 | 36 | public RouteDecision build() { 37 | return new RouteDecision(sql, reason, cause); 38 | } 39 | 40 | @Override 41 | public boolean equals(Object o) { 42 | if (this == o) return true; 43 | if (o == null || getClass() != o.getClass()) return false; 44 | RouteDecisionBuilder that = (RouteDecisionBuilder) o; 45 | return Objects.equals(sql, that.sql) 46 | && Objects.equals(reason, that.reason) 47 | && Objects.equals(cause, that.cause); 48 | } 49 | 50 | @Override 51 | public int hashCode() { 52 | return Objects.hash(sql, reason, cause); 53 | } 54 | 55 | @Override 56 | public String toString() { 57 | return "RouteDecisionBuilder{" + 58 | "sql='" + sql + '\'' + 59 | ", reason=" + reason + 60 | ", cause=" + cause + 61 | '}'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/SqlQuery.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | public final class SqlQuery { 4 | private static final int SELECT_FOR_UPDATE_SUFFIX_LIMIT = 100; 5 | private final String sql; 6 | 7 | public SqlQuery(String sql, boolean compatibleWithPreviousVersion) { 8 | if (sql == null) { 9 | throw new RuntimeException("An SqlQuery must have an SQL query string"); 10 | } 11 | this.sql = sql; 12 | } 13 | 14 | public boolean isWriteOperation(SqlFunction sqlFunction) { 15 | return sqlFunction.isFunctionCall(sql) || isUpdate() || isDelete() || isInsert(); 16 | } 17 | 18 | public boolean isSelectForUpdate() { 19 | final String trimmedQuery = trimForSelectForUpdateCheck(); 20 | return containsFor(trimmedQuery) && (containsUpdate(trimmedQuery) || containsShare(trimmedQuery)); 21 | } 22 | 23 | private boolean containsUpdate(String trimmedQuery) { 24 | return trimmedQuery.contains("update") || trimmedQuery.contains("UPDATE"); 25 | } 26 | 27 | private boolean containsShare(String trimmedQuery) { 28 | return trimmedQuery.contains("share") || trimmedQuery.contains("SHARE"); 29 | } 30 | 31 | private boolean containsFor(String trimmedQuery) { 32 | return trimmedQuery.contains("for") || trimmedQuery.contains("FOR"); 33 | } 34 | 35 | public boolean isInsert() { 36 | return sql.startsWith("insert") || sql.startsWith("INSERT"); 37 | } 38 | 39 | public boolean isSqlSet() { 40 | return sql.startsWith("set") || sql.startsWith("SET"); 41 | } 42 | 43 | private boolean isUpdate() { 44 | return sql.startsWith("update") || sql.startsWith("UPDATE"); 45 | } 46 | 47 | private boolean isDelete() { 48 | return sql.startsWith("delete") || sql.startsWith("DELETE"); 49 | } 50 | 51 | private String trimForSelectForUpdateCheck() { 52 | if (sql.length() < SELECT_FOR_UPDATE_SUFFIX_LIMIT) { 53 | return sql; 54 | } else { 55 | return sql.substring(sql.length() - SELECT_FOR_UPDATE_SUFFIX_LIMIT); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/SqlRunnable.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import java.sql.SQLException; 4 | 5 | public interface SqlRunnable { 6 | void run() throws SQLException; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/StatementOperation.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | 4 | import java.sql.SQLException; 5 | import java.sql.Statement; 6 | 7 | public interface StatementOperation { 8 | void accept(T t) throws SQLException; 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/Warnings.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import java.sql.SQLWarning; 4 | 5 | public final class Warnings { 6 | private SQLWarning warning; 7 | 8 | public void saveWarning(SQLWarning warning) { 9 | if (warning == null || isLastWarning(warning)) { 10 | return; 11 | } 12 | if (this.warning == null) { 13 | this.warning = warning; 14 | } else { 15 | this.warning.setNextWarning(warning); 16 | } 17 | } 18 | 19 | public SQLWarning getWarning() { 20 | return warning; 21 | } 22 | 23 | public void clear() { 24 | this.warning = null; 25 | } 26 | 27 | private boolean isLastWarning(SQLWarning warning) { 28 | if (this.warning == null) { 29 | return false; 30 | } 31 | SQLWarning lastWarning = this.warning; 32 | for (int i = 0; i < 100; i++) { 33 | if (lastWarning.getNextWarning() == null) { 34 | return lastWarning.equals(warning); 35 | } else 36 | lastWarning = lastWarning.getNextWarning(); 37 | } 38 | return true; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraCluster.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import java.util.Objects; 4 | 5 | import static com.atlassian.db.replica.internal.aurora.AuroraCluster.AuroraClusterBuilder.anAuroraCluster; 6 | 7 | public final class AuroraCluster { 8 | private final String clusterName; 9 | private final String clusterPrefix; 10 | 11 | private AuroraCluster(String clusterName, String clusterPrefix) { 12 | this.clusterName = clusterName; 13 | this.clusterPrefix = clusterPrefix; 14 | } 15 | 16 | public static AuroraCluster parse(String cluster) { 17 | Objects.requireNonNull(cluster); 18 | 19 | String[] chunks = splitByLastOccurence("-", cluster); 20 | if (chunks.length == 1) { 21 | String clusterName = chunks[0]; 22 | return anAuroraCluster(clusterName).build(); 23 | } else { 24 | String clusterName = chunks[0]; 25 | String clusterPrefix = chunks[1]; 26 | return anAuroraCluster(clusterName).clusterPrefix(clusterPrefix).build(); 27 | } 28 | } 29 | 30 | private static String[] splitByLastOccurence(String delimiter, String str) { 31 | int i = str.lastIndexOf(delimiter); 32 | if (i == -1) { 33 | return new String[]{str}; 34 | } else { 35 | return new String[]{str.substring(i + 1), str.substring(0, i)}; 36 | } 37 | } 38 | 39 | @Override 40 | public String toString() { 41 | if (clusterPrefix != null && !clusterPrefix.isEmpty()) { 42 | return String.format("%s-%s", clusterPrefix, clusterName); 43 | } else { 44 | return clusterName; 45 | } 46 | } 47 | 48 | @Override 49 | public boolean equals(Object o) { 50 | if (this == o) return true; 51 | if (o == null || getClass() != o.getClass()) return false; 52 | 53 | AuroraCluster that = (AuroraCluster) o; 54 | 55 | if (!Objects.equals(clusterName, that.clusterName)) return false; 56 | return Objects.equals(clusterPrefix, that.clusterPrefix); 57 | } 58 | 59 | @Override 60 | public int hashCode() { 61 | int result = clusterName != null ? clusterName.hashCode() : 0; 62 | result = 31 * result + (clusterPrefix != null ? clusterPrefix.hashCode() : 0); 63 | return result; 64 | } 65 | 66 | public String getClusterName() { 67 | return clusterName; 68 | } 69 | 70 | public String getClusterPrefix() { 71 | return clusterPrefix; 72 | } 73 | 74 | 75 | public static final class AuroraClusterBuilder { 76 | private String clusterName; 77 | private String clusterPrefix; 78 | 79 | private AuroraClusterBuilder() { 80 | } 81 | 82 | public static AuroraClusterBuilder anAuroraCluster(String clusterName) { 83 | return new AuroraClusterBuilder().clusterName(clusterName); 84 | } 85 | 86 | public AuroraClusterBuilder clusterName(String clusterName) { 87 | this.clusterName = clusterName; 88 | return this; 89 | } 90 | 91 | public AuroraClusterBuilder clusterPrefix(String clusterPrefix) { 92 | this.clusterPrefix = clusterPrefix; 93 | return this; 94 | } 95 | 96 | public AuroraCluster build() { 97 | return new AuroraCluster(clusterName, clusterPrefix); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraEndpoint.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import java.util.Objects; 4 | import java.util.regex.Matcher; 5 | import java.util.regex.Pattern; 6 | 7 | public final class AuroraEndpoint { 8 | private static final String SERVER_ID_PATTERN = "([^.]+)"; 9 | private static final String CLUSTER_PATTERN = "([^.]+)"; 10 | private static final String DNS_PATTERN = "(.*)"; 11 | private static final Pattern ENDPOINT_PATTERN = Pattern.compile(SERVER_ID_PATTERN + "." + CLUSTER_PATTERN + "." + DNS_PATTERN); 12 | 13 | private final String serverId; 14 | private final AuroraCluster cluster; 15 | private final RdsDns dns; 16 | 17 | public AuroraEndpoint(String serverId, AuroraCluster cluster, RdsDns dns) { 18 | this.serverId = serverId; 19 | this.cluster = cluster; 20 | this.dns = dns; 21 | } 22 | 23 | public static AuroraEndpoint parse(String readerEndpoint) { 24 | Objects.requireNonNull(readerEndpoint); 25 | 26 | Matcher matcher = ENDPOINT_PATTERN.matcher(readerEndpoint); 27 | if (!matcher.matches()) { 28 | throw new IllegalArgumentException(String.format("Can't parse %s.", readerEndpoint)); 29 | } 30 | return new AuroraEndpoint( 31 | matcher.group(1), 32 | AuroraCluster.parse(matcher.group(2)), 33 | RdsDns.parse(matcher.group(3)) 34 | ); 35 | } 36 | 37 | public String getServerId() { 38 | return serverId; 39 | } 40 | 41 | public AuroraCluster getCluster() { 42 | return cluster; 43 | } 44 | 45 | public RdsDns getDns() { 46 | return dns; 47 | } 48 | 49 | @Override 50 | public String toString() { 51 | return String.format("%s.%s.%s", serverId, cluster.toString(), dns.toString()); 52 | } 53 | 54 | 55 | public static final class AuroraEndpointBuilder { 56 | private String serverId; 57 | private AuroraCluster cluster; 58 | private RdsDns dns; 59 | 60 | private AuroraEndpointBuilder() { 61 | } 62 | 63 | public static AuroraEndpointBuilder anAuroraEndpoint(AuroraEndpoint endpoint) { 64 | return new AuroraEndpointBuilder() 65 | .serverId(endpoint.serverId) 66 | .cluster(endpoint.cluster) 67 | .dns(endpoint.dns); 68 | } 69 | 70 | public static AuroraEndpointBuilder anAuroraEndpoint() { 71 | return new AuroraEndpointBuilder(); 72 | } 73 | 74 | public AuroraEndpointBuilder serverId(String serverId) { 75 | this.serverId = serverId; 76 | return this; 77 | } 78 | 79 | public AuroraEndpointBuilder cluster(AuroraCluster cluster) { 80 | this.cluster = cluster; 81 | return this; 82 | } 83 | 84 | public AuroraEndpointBuilder dns(RdsDns dns) { 85 | this.dns = dns; 86 | return this; 87 | } 88 | 89 | public AuroraEndpoint build() { 90 | return new AuroraEndpoint(serverId, cluster, dns); 91 | } 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraEndpoints.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import static com.atlassian.db.replica.internal.aurora.AuroraCluster.AuroraClusterBuilder.anAuroraCluster; 4 | import static com.atlassian.db.replica.internal.aurora.AuroraEndpoint.AuroraEndpointBuilder.anAuroraEndpoint; 5 | 6 | public class AuroraEndpoints { 7 | private AuroraEndpoints() { 8 | } 9 | 10 | /** 11 | * Transforms reader endpoint to instance endpoint 12 | */ 13 | public static AuroraEndpoint instanceEndpoint(AuroraEndpoint readerEndpoint, String serverId) { 14 | return anAuroraEndpoint(readerEndpoint) 15 | .serverId(serverId) 16 | .cluster(anAuroraCluster(readerEndpoint.getCluster().getClusterName()).clusterPrefix(null).build()) 17 | .build(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraJdbcUrl.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import com.atlassian.db.replica.api.jdbc.JdbcProtocol; 4 | import com.atlassian.db.replica.api.jdbc.JdbcUrl; 5 | 6 | public class AuroraJdbcUrl { 7 | private static final String PREFIX = "jdbc:postgresql://"; 8 | 9 | private final AuroraEndpoint endpoint; 10 | private final String databaseName; 11 | 12 | public AuroraJdbcUrl(AuroraEndpoint endpoint, String databaseName) { 13 | this.endpoint = endpoint; 14 | this.databaseName = databaseName; 15 | } 16 | 17 | public AuroraEndpoint getEndpoint() { 18 | return endpoint; 19 | } 20 | 21 | public String getDatabaseName() { 22 | return databaseName; 23 | } 24 | 25 | public JdbcUrl toJdbcUrl() { 26 | return JdbcUrl.builder() 27 | .protocol(JdbcProtocol.POSTGRES) 28 | .endpoint(endpoint.toString()) 29 | .database(databaseName) 30 | .build(); 31 | } 32 | 33 | @Override 34 | public String toString() { 35 | return String.format("%s%s/%s", PREFIX, endpoint, databaseName); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraReplicaNode.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import com.atlassian.db.replica.api.exception.ReadReplicaConnectionCreationException; 4 | import com.atlassian.db.replica.spi.ReplicaConnectionProvider; 5 | import com.atlassian.db.replica.api.Database; 6 | 7 | import java.sql.Connection; 8 | import java.sql.SQLException; 9 | import java.util.function.Supplier; 10 | 11 | public class AuroraReplicaNode implements Database { 12 | private final String id; 13 | private final ReplicaConnectionProvider replicaConnectionProvider; 14 | 15 | public AuroraReplicaNode( 16 | String id, 17 | ReplicaConnectionProvider replicaConnectionProvider 18 | ) { 19 | this.id = id; 20 | this.replicaConnectionProvider = replicaConnectionProvider; 21 | } 22 | 23 | @Override 24 | public String getId() { 25 | return id; 26 | } 27 | 28 | @Override 29 | public Supplier getConnectionSupplier() { 30 | return () -> { 31 | try { 32 | return replicaConnectionProvider.getReplicaConnection(); 33 | } catch (SQLException exception) { 34 | throw new ReadReplicaConnectionCreationException(exception); 35 | } 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/AuroraReplicasDiscoverer.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import com.atlassian.db.replica.internal.logs.LazyLogger; 4 | import com.atlassian.db.replica.spi.Logger; 5 | 6 | import java.sql.Connection; 7 | import java.sql.ResultSet; 8 | import java.sql.SQLException; 9 | import java.util.LinkedList; 10 | import java.util.List; 11 | 12 | import static com.atlassian.db.replica.internal.aurora.AuroraEndpoints.instanceEndpoint; 13 | import static java.util.stream.Collectors.toList; 14 | 15 | /** 16 | * Allows discovery of Aurora Replicas cluster information 17 | */ 18 | public final class AuroraReplicasDiscoverer { 19 | private final AuroraJdbcUrl readerUrl; 20 | 21 | private final Logger logger; 22 | private final LazyLogger lazyLogger; 23 | 24 | public AuroraReplicasDiscoverer(AuroraJdbcUrl readerUrl, Logger logger, LazyLogger lazyLogger) { 25 | this.readerUrl = readerUrl; 26 | this.logger = logger; 27 | this.lazyLogger = lazyLogger; 28 | } 29 | 30 | /** 31 | * Provides jdbc urls for discovered replicas 32 | * 33 | * @return list of jdbc urls 34 | */ 35 | public List fetchReplicasUrls(Connection connection) throws SQLException { 36 | return fetchReplicasServerIds(connection) 37 | .stream() 38 | .map(serverId -> 39 | new AuroraJdbcUrl( 40 | instanceEndpoint(readerUrl.getEndpoint(), serverId), 41 | readerUrl.getDatabaseName() 42 | ) 43 | ) 44 | .collect(toList()); 45 | } 46 | 47 | private List fetchReplicasServerIds(Connection connection) throws SQLException { 48 | List ids = new LinkedList<>(); 49 | final String sql = "SELECT server_id, durable_lsn, current_read_lsn, feedback_xmin, " + 50 | "round(extract(milliseconds from (now()-last_update_timestamp))) as state_lag_in_msec, replica_lag_in_msec " + 51 | "FROM aurora_replica_status() " + 52 | "WHERE session_id != 'MASTER_SESSION_ID' and last_update_timestamp > NOW() - INTERVAL '5 minutes';"; 53 | try (ResultSet rs = 54 | connection.prepareStatement(sql).executeQuery()) { 55 | while (rs.next()) { 56 | String serverId = rs.getString("server_id"); 57 | long replicaLagInMs = rs.getLong("replica_lag_in_msec"); 58 | long durableLsn = rs.getLong("durable_lsn"); 59 | long currentReadLsn = rs.getLong("current_read_lsn"); 60 | long feedbackXmin = rs.getLong("feedback_xmin"); 61 | long stateLag = rs.getLong("state_lag_in_msec"); 62 | /* Idea for future logging 63 | logger.debug(String.format( 64 | "server_id=%s, replica_lag_in_ms=%d, durable_lsn=%d, current_read_lsn=%d, feedback_xmin=%d, state_lag=%d", 65 | serverId, 66 | replicaLagInMs, 67 | durableLsn, 68 | currentReadLsn, 69 | feedbackXmin, 70 | stateLag 71 | )); 72 | */ 73 | ids.add(serverId); 74 | } 75 | } 76 | return ids; 77 | } 78 | } 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/RdsDns.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import java.util.Objects; 4 | import java.util.regex.Matcher; 5 | import java.util.regex.Pattern; 6 | 7 | public final class RdsDns { 8 | private static final Pattern DNS_PATTERN = Pattern.compile("([^.]+).([^:]*):?([0-9]+)?"); 9 | private final String region; 10 | private final String domain; 11 | private final Integer port; 12 | 13 | public RdsDns(String region, String domain, Integer port) { 14 | this.region = region; 15 | this.domain = domain; 16 | this.port = port; 17 | } 18 | 19 | public static RdsDns parse(String dns) { 20 | Objects.requireNonNull(dns); 21 | 22 | Matcher matcher = DNS_PATTERN.matcher(dns); 23 | matcher.matches(); 24 | return new RdsDns(matcher.group(1), matcher.group(2), parseNullableInteger(groupOrNull(matcher, 3))); 25 | } 26 | 27 | private static String groupOrNull(Matcher matcher, int n) { 28 | try { 29 | return matcher.group(n); 30 | } catch (IndexOutOfBoundsException e) { 31 | return null; 32 | } 33 | } 34 | 35 | private static Integer parseNullableInteger(String str) { 36 | if (str == null) { 37 | return null; 38 | } else { 39 | return Integer.valueOf(str); 40 | } 41 | } 42 | 43 | public String getRegion() { 44 | return region; 45 | } 46 | 47 | public String getDomain() { 48 | return domain; 49 | } 50 | 51 | public Integer getPort() { 52 | return port; 53 | } 54 | 55 | @Override 56 | public String toString() { 57 | if (port != null) { 58 | return String.format("%s.%s:%s", region, domain, port); 59 | } else { 60 | return String.format("%s.%s", region, domain); 61 | } 62 | } 63 | 64 | @Override 65 | public boolean equals(Object o) { 66 | if (this == o) return true; 67 | if (o == null || getClass() != o.getClass()) return false; 68 | 69 | RdsDns rdsDns = (RdsDns) o; 70 | 71 | if (!Objects.equals(region, rdsDns.region)) return false; 72 | if (!Objects.equals(domain, rdsDns.domain)) return false; 73 | return Objects.equals(port, rdsDns.port); 74 | } 75 | 76 | @Override 77 | public int hashCode() { 78 | int result = region != null ? region.hashCode() : 0; 79 | result = 31 * result + (domain != null ? domain.hashCode() : 0); 80 | result = 31 * result + (port != null ? port.hashCode() : 0); 81 | return result; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/ReadReplicaDiscovererCreationException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | public class ReadReplicaDiscovererCreationException extends RuntimeException { 4 | 5 | public ReadReplicaDiscovererCreationException(Throwable cause) { 6 | super("Failed to create AuroraReplicasDiscoverer", cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/ReadReplicaDiscoveryOperationException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | public class ReadReplicaDiscoveryOperationException extends RuntimeException { 4 | 5 | public ReadReplicaDiscoveryOperationException(Throwable cause) { 6 | super("Failure during read replicas discovery operation", cause); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/ReadReplicaNodeLabelingOperationException.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | public class ReadReplicaNodeLabelingOperationException extends RuntimeException { 4 | 5 | public ReadReplicaNodeLabelingOperationException(String replicaId, Throwable cause) { 6 | super("Failed to label a replica connection with replica id: " + replicaId, cause); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/aurora/ReplicaNode.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | 6 | 7 | public class ReplicaNode { 8 | private static final String AURORA_REPLICA_ID = "replicaId"; 9 | 10 | public Connection mark(final Connection connection, final String replicaId) { 11 | try { 12 | connection.getClientInfo().setProperty(AURORA_REPLICA_ID, replicaId); 13 | } catch (SQLException exception) { 14 | throw new ReadReplicaNodeLabelingOperationException(replicaId, exception); 15 | } 16 | return connection; 17 | } 18 | 19 | public String get(Connection replica) { 20 | try { 21 | return replica.getClientInfo(AURORA_REPLICA_ID); 22 | } catch (SQLException e) { 23 | // LOG.withCustomerData().error("Failed to fetch aurora server id", e); 24 | return null; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/circuitbreaker/BreakOnNotSupportedOperations.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.circuitbreaker; 2 | 3 | import com.atlassian.db.replica.internal.ReadReplicaUnsupportedOperationException; 4 | 5 | import java.sql.SQLFeatureNotSupportedException; 6 | 7 | import static com.atlassian.db.replica.internal.circuitbreaker.BreakerState.CLOSED; 8 | import static com.atlassian.db.replica.internal.circuitbreaker.BreakerState.OPEN; 9 | 10 | public class BreakOnNotSupportedOperations implements CircuitBreaker { 11 | private static volatile BreakerState state = CLOSED; 12 | 13 | @Override 14 | public BreakerState getState() { 15 | return state; 16 | } 17 | 18 | @Override 19 | public void handle(Throwable throwable) { 20 | if (throwable instanceof ReadReplicaUnsupportedOperationException || throwable instanceof SQLFeatureNotSupportedException) { 21 | state = OPEN; 22 | } 23 | } 24 | 25 | public static void reset() { 26 | state = CLOSED; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/circuitbreaker/BreakerHandler.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.circuitbreaker; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | import com.atlassian.db.replica.internal.SqlRunnable; 5 | 6 | import java.sql.SQLException; 7 | 8 | public class BreakerHandler { 9 | private final CircuitBreaker breaker; 10 | 11 | public BreakerHandler(CircuitBreaker breaker) { 12 | this.breaker = breaker; 13 | } 14 | 15 | public T handle(SqlCall call) throws SQLException { 16 | try { 17 | return call.call(); 18 | } catch (Throwable throwable) { 19 | breaker.handle(throwable); 20 | throw throwable; 21 | } 22 | } 23 | 24 | public void handle(SqlRunnable call) throws SQLException { 25 | try { 26 | call.run(); 27 | } catch (Throwable throwable) { 28 | breaker.handle(throwable); 29 | throw throwable; 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/circuitbreaker/BreakerState.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.circuitbreaker; 2 | 3 | /** 4 | * States of circuit breakers. 5 | */ 6 | public enum BreakerState { 7 | OPEN, 8 | HALF_CLOSED, 9 | CLOSED 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/circuitbreaker/CircuitBreaker.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.circuitbreaker; 2 | 3 | 4 | public interface CircuitBreaker { 5 | BreakerState getState(); 6 | 7 | void handle(Throwable throwable); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/circuitbreaker/ClosedBreaker.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.circuitbreaker; 2 | 3 | public class ClosedBreaker implements CircuitBreaker { 4 | @Override 5 | public BreakerState getState() { 6 | return BreakerState.CLOSED; 7 | } 8 | 9 | @Override 10 | public void handle(Throwable throwable) { 11 | 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/ConnectionProviderLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import com.atlassian.db.replica.spi.ConnectionProvider; 4 | 5 | import java.sql.Connection; 6 | import java.sql.SQLException; 7 | 8 | import static java.lang.String.format; 9 | 10 | public final class ConnectionProviderLogger implements ConnectionProvider { 11 | private final ConnectionProvider delegate; 12 | private final LazyLogger logger; 13 | 14 | public ConnectionProviderLogger(ConnectionProvider delegate, LazyLogger logger) { 15 | this.delegate = delegate; 16 | this.logger = logger; 17 | } 18 | 19 | @Override 20 | public boolean isReplicaAvailable() { 21 | return delegate.isReplicaAvailable(); 22 | } 23 | 24 | @Override 25 | public Connection getMainConnection() throws SQLException { 26 | try { 27 | final Connection mainConnection = delegate.getMainConnection(); 28 | logger.debug(() -> format("ConnectionProvider#getMainConnection(connection=%s)", mainConnection)); 29 | return mainConnection; 30 | } catch (Exception e) { 31 | logger.debug(() -> "Failed ConnectionProvider#getMainConnection", e); 32 | throw e; 33 | } 34 | } 35 | 36 | @Override 37 | public Connection getReplicaConnection() throws SQLException { 38 | try { 39 | final Connection replicaConnection = delegate.getReplicaConnection(); 40 | logger.debug(() -> format("ConnectionProvider#getReplicaConnection(connection=%s)", replicaConnection)); 41 | return replicaConnection; 42 | } catch (Exception e) { 43 | logger.debug(() -> "Failed ConnectionProvider#getReplicaConnection", e); 44 | throw e; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/DelegatingLazyLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import com.atlassian.db.replica.spi.Logger; 4 | 5 | import java.util.function.Supplier; 6 | 7 | public class DelegatingLazyLogger implements LazyLogger { 8 | private final Logger log; 9 | 10 | public DelegatingLazyLogger(Logger log) { 11 | this.log = log; 12 | } 13 | 14 | public void debug(Supplier message) { 15 | log.debug(message.get()); 16 | } 17 | 18 | public void debug(Supplier message, Throwable t) { 19 | log.debug(message.get(), t); 20 | } 21 | 22 | public void info(Supplier message) { 23 | log.info(message.get()); 24 | } 25 | 26 | public void info(Supplier message, Throwable t) { 27 | log.info(message.get(), t); 28 | } 29 | 30 | public void warn(Supplier message) { 31 | log.warn(message.get()); 32 | } 33 | 34 | public void warn(Supplier message, Throwable t) { 35 | log.warn(message.get(), t); 36 | } 37 | 38 | public void error(Supplier message) { 39 | log.error(message.get()); 40 | } 41 | 42 | public void error(Supplier message, Throwable t) { 43 | log.error(message.get(), t); 44 | } 45 | 46 | @Override 47 | public boolean isEnabled() { 48 | return true; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/LazyLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import java.util.function.Supplier; 4 | 5 | public interface LazyLogger { 6 | void debug(Supplier message); 7 | 8 | void debug(Supplier message, Throwable t); 9 | 10 | void info(Supplier message); 11 | 12 | void info(Supplier message, Throwable t); 13 | 14 | void warn(Supplier message); 15 | 16 | void warn(Supplier message, Throwable t); 17 | 18 | void error(Supplier message); 19 | 20 | void error(Supplier message, Throwable t); 21 | 22 | boolean isEnabled(); 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/NoopLazyLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import java.util.function.Supplier; 4 | 5 | public class NoopLazyLogger implements LazyLogger{ 6 | @Override 7 | public void debug(Supplier message) { 8 | //noop 9 | } 10 | 11 | @Override 12 | public void debug(Supplier message, Throwable t) { 13 | //noop 14 | } 15 | 16 | @Override 17 | public void info(Supplier message) { 18 | //noop 19 | } 20 | 21 | @Override 22 | public void info(Supplier message, Throwable t) { 23 | //noop 24 | } 25 | 26 | @Override 27 | public void warn(Supplier message) { 28 | //noop 29 | } 30 | 31 | @Override 32 | public void warn(Supplier message, Throwable t) { 33 | //noop 34 | } 35 | 36 | @Override 37 | public void error(Supplier message) { 38 | //noop 39 | } 40 | 41 | @Override 42 | public void error(Supplier message, Throwable t) { 43 | //noop 44 | } 45 | 46 | @Override 47 | public boolean isEnabled() { 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/ReplicaConsistencyLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import com.atlassian.db.replica.spi.ReplicaConsistency; 4 | 5 | import java.sql.Connection; 6 | import java.util.function.Supplier; 7 | 8 | import static java.lang.String.format; 9 | 10 | public final class ReplicaConsistencyLogger implements ReplicaConsistency { 11 | private final ReplicaConsistency delegate; 12 | private final LazyLogger logger; 13 | 14 | public ReplicaConsistencyLogger(ReplicaConsistency delegate, LazyLogger logger) { 15 | this.delegate = delegate; 16 | this.logger = logger; 17 | } 18 | 19 | @Override 20 | public void write(Connection main) { 21 | try { 22 | delegate.write(main); 23 | logger.debug(() -> format("ReplicaConsistency#write(connection=%s)", main)); 24 | } catch (Exception e) { 25 | logger.debug(() -> format("Failed ReplicaConsistency#write(connection=%s)", main), e); 26 | throw e; 27 | } 28 | } 29 | 30 | @Override 31 | public void preCommit(Connection main) { 32 | try { 33 | delegate.preCommit(main); 34 | logger.debug(() -> format("ReplicaConsistency#preCommit(connection=%s)", main)); 35 | } catch (Exception e) { 36 | logger.debug(() -> format("Failed ReplicaConsistency#preCommit(connection=%s)", main), e); 37 | throw e; 38 | } 39 | } 40 | 41 | @Override 42 | public boolean isConsistent(Supplier replica) { 43 | try { 44 | final boolean consistent = delegate.isConsistent(replica); 45 | logger.debug(() -> format("ReplicaConsistency#isConsistent = %b", consistent)); 46 | return consistent; 47 | } catch (Exception e) { 48 | logger.debug(() -> "Failed ReplicaConsistency#isConsistent", e); 49 | throw e; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/StateAwareLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import com.atlassian.db.replica.internal.state.State; 4 | 5 | import java.util.function.Supplier; 6 | 7 | public class StateAwareLogger implements LazyLogger { 8 | private final Supplier stateSupplier; 9 | private final LazyLogger logger; 10 | 11 | public StateAwareLogger(Supplier stateSupplier, LazyLogger logger) { 12 | this.stateSupplier = stateSupplier; 13 | this.logger = logger; 14 | } 15 | 16 | @Override 17 | public void debug(Supplier message) { 18 | if (isEnabled()) { 19 | logger.debug(messageWithStatus(message)); 20 | } 21 | } 22 | 23 | @Override 24 | public void debug(Supplier message, Throwable t) { 25 | if (isEnabled()) { 26 | logger.debug(messageWithStatus(message), t); 27 | } 28 | } 29 | 30 | @Override 31 | public void info(Supplier message) { 32 | if (isEnabled()) { 33 | logger.info(messageWithStatus(message)); 34 | } 35 | } 36 | 37 | @Override 38 | public void info(Supplier message, Throwable t) { 39 | if (isEnabled()) { 40 | logger.info(messageWithStatus(message), t); 41 | } 42 | } 43 | 44 | @Override 45 | public void warn(Supplier message) { 46 | if (isEnabled()) { 47 | logger.warn(messageWithStatus(message)); 48 | } 49 | } 50 | 51 | @Override 52 | public void warn(Supplier message, Throwable t) { 53 | if (isEnabled()) { 54 | logger.warn(messageWithStatus(message), t); 55 | } 56 | } 57 | 58 | @Override 59 | public void error(Supplier message) { 60 | if (isEnabled()) { 61 | logger.error(messageWithStatus(message)); 62 | } 63 | } 64 | 65 | @Override 66 | public void error(Supplier message, Throwable t) { 67 | if (isEnabled()) { 68 | logger.error(messageWithStatus(message), t); 69 | } 70 | } 71 | 72 | @Override 73 | public boolean isEnabled() { 74 | return logger.isEnabled(); 75 | } 76 | 77 | private Supplier messageWithStatus(Supplier message) { 78 | return () -> "[state=" + stateSupplier.get().getName() + "] " + message.get(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/logs/TaggedLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.logs; 2 | 3 | import java.util.function.Supplier; 4 | 5 | public class TaggedLogger implements LazyLogger { 6 | private final String key; 7 | private final String value; 8 | private final LazyLogger logger; 9 | 10 | public TaggedLogger(String key, String value, LazyLogger logger) { 11 | this.key = key; 12 | this.value = value; 13 | this.logger = logger; 14 | } 15 | 16 | @Override 17 | public void debug(Supplier message) { 18 | if (isEnabled()) { 19 | logger.debug(messageWithDualConnectionUuid(message)); 20 | } 21 | } 22 | 23 | @Override 24 | public void debug(Supplier message, Throwable t) { 25 | if (isEnabled()) { 26 | logger.debug(messageWithDualConnectionUuid(message), t); 27 | } 28 | } 29 | 30 | @Override 31 | public void info(Supplier message) { 32 | if (isEnabled()) { 33 | logger.info(messageWithDualConnectionUuid(message)); 34 | } 35 | } 36 | 37 | @Override 38 | public void info(Supplier message, Throwable t) { 39 | if (isEnabled()) { 40 | logger.info(messageWithDualConnectionUuid(message), t); 41 | } 42 | } 43 | 44 | @Override 45 | public void warn(Supplier message) { 46 | if (isEnabled()) { 47 | logger.warn(messageWithDualConnectionUuid(message)); 48 | } 49 | } 50 | 51 | @Override 52 | public void warn(Supplier message, Throwable t) { 53 | if (isEnabled()) { 54 | logger.warn(messageWithDualConnectionUuid(message), t); 55 | } 56 | } 57 | 58 | @Override 59 | public void error(Supplier message) { 60 | if (isEnabled()) { 61 | logger.error(messageWithDualConnectionUuid(message)); 62 | } 63 | } 64 | 65 | @Override 66 | public void error(Supplier message, Throwable t) { 67 | if (isEnabled()) { 68 | logger.error(messageWithDualConnectionUuid(message), t); 69 | } 70 | } 71 | 72 | @Override 73 | public boolean isEnabled() { 74 | return logger.isEnabled(); 75 | } 76 | 77 | private Supplier messageWithDualConnectionUuid(Supplier message) { 78 | return () -> "[" + key + "=" + value + "] " + message.get(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/state/NoOpStateListener.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.state; 2 | 3 | public final class NoOpStateListener implements StateListener { 4 | @Override 5 | public void transition(State from, State to) { 6 | // NoOp implementation. 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/state/State.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.state; 2 | 3 | 4 | import java.util.Objects; 5 | 6 | public final class State { 7 | private final String name; 8 | 9 | private State(String name) { 10 | this.name = name; 11 | } 12 | 13 | public static final State NOT_INITIALISED = new State("NOT_INITIALISED"); 14 | public static final State MAIN = new State("MAIN"); 15 | public static final State REPLICA = new State("REPLICA"); 16 | public static final State CLOSED = new State("CLOSED"); 17 | public static final State COMMITED_MAIN = new State("COMMITED_MAIN"); 18 | 19 | public String getName() { 20 | return name; 21 | } 22 | 23 | @Override 24 | public String toString() { 25 | return "State{" + 26 | "name='" + name + '\'' + 27 | '}'; 28 | } 29 | 30 | @Override 31 | public boolean equals(Object o) { 32 | if (this == o) return true; 33 | if (o == null || getClass() != o.getClass()) return false; 34 | State states = (State) o; 35 | return Objects.equals(name, states.name); 36 | } 37 | 38 | @Override 39 | public int hashCode() { 40 | return Objects.hash(name); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/state/StateListener.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.state; 2 | 3 | 4 | public interface StateListener { 5 | 6 | /** 7 | * Informs that {@link com.atlassian.db.replica.api.DualConnection } changed {@link State}. 8 | * 9 | * @param from {@link State} before the transition. 10 | * @param to {@link State} after the transition. 11 | */ 12 | void transition(State from, State to); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/util/Comparables.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.util; 2 | 3 | public class Comparables { 4 | public static > T max(T c1, T c2) { 5 | if (c1 == c2) { 6 | return c1; 7 | } else if (c1 == null) { 8 | return c2; 9 | } else if (c2 == null) { 10 | return c1; 11 | } else { 12 | return c1.compareTo(c2) > 0 ? c1 : c2; 13 | } 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/internal/util/ThreadSafe.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.util; 2 | 3 | 4 | import java.lang.annotation.Documented; 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | 10 | /* 11 | * Copyright (c) 2005 Brian Goetz and Tim Peierls 12 | * Released under the Creative Commons Attribution License 13 | * (http://creativecommons.org/licenses/by/2.5) 14 | * Official home: http://www.jcip.net 15 | * 16 | * Any republication or derived work distributed in source code form 17 | * must include this copyright and license notice. 18 | */ 19 | 20 | /** 21 | * The class to which this annotation is applied is thread-safe. This means that 22 | * no sequences of accesses (reads and writes to public fields, calls to public methods) 23 | * may put the object into an invalid state, regardless of the interleaving of those actions 24 | * by the runtime, and without requiring any additional synchronization or coordination on the 25 | * part of the caller. 26 | */ 27 | @Documented 28 | @Target(ElementType.TYPE) 29 | @Retention(RetentionPolicy.RUNTIME) 30 | public @interface ThreadSafe { 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/Cache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import com.atlassian.db.replica.internal.*; 4 | 5 | import java.util.*; 6 | 7 | /** 8 | * Holds a single value. Might be empty. 9 | * 10 | * @param cached value 11 | */ 12 | public interface Cache { 13 | 14 | static > Cache cacheMonotonicValuesInMemory() { 15 | return new MonotonicMemoryCache<>(); 16 | } 17 | 18 | /** 19 | * @return last known value or empty if it's unknown, not null 20 | */ 21 | Optional get(); 22 | 23 | /** 24 | * @param value last known value 25 | */ 26 | void put(T value); 27 | 28 | /** 29 | * Forgets the last known value. 30 | */ 31 | void reset(); 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/ConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | 6 | public interface ConnectionProvider extends ReplicaConnectionProvider { 7 | 8 | boolean isReplicaAvailable(); 9 | 10 | /** 11 | * @return a connection to the main database 12 | */ 13 | Connection getMainConnection() throws SQLException; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/DatabaseCall.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | import com.atlassian.db.replica.api.reason.RouteDecision; 5 | 6 | import java.sql.SQLException; 7 | 8 | /** 9 | * Intercepts call to a database. 10 | */ 11 | public interface DatabaseCall { 12 | T call(final SqlCall call, RouteDecision decision) throws SQLException; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/DirtyConnectionCloseHook.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | 6 | /** 7 | * The hook will be invoked before closing a connection with uncommitted data. 8 | */ 9 | public interface DirtyConnectionCloseHook { 10 | void onClose(Connection connection) throws SQLException; 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/Logger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | public interface Logger { 4 | 5 | default void debug(String message) {} 6 | 7 | default void debug(String message, Throwable t) {} 8 | 9 | default void info(String message) {} 10 | 11 | default void info(String message, Throwable t) {} 12 | 13 | default void warn(String message) {} 14 | 15 | default void warn(String message, Throwable t) {} 16 | 17 | default void error(String message) {} 18 | 19 | default void error(String message, Throwable t) {} 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/ReplicaConnectionPerUrlProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import com.atlassian.db.replica.api.jdbc.JdbcUrl; 4 | 5 | @FunctionalInterface 6 | public interface ReplicaConnectionPerUrlProvider { 7 | ReplicaConnectionProvider getReplicaConnectionProvider(JdbcUrl replicaUrl); 8 | } 9 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/ReplicaConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import java.sql.Connection; 4 | import java.sql.SQLException; 5 | 6 | @FunctionalInterface 7 | public interface ReplicaConnectionProvider { 8 | /** 9 | * @return a connection to a replica database 10 | */ 11 | Connection getReplicaConnection() throws SQLException; 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/ReplicaConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import com.atlassian.db.replica.internal.util.ThreadSafe; 4 | 5 | import java.sql.Connection; 6 | import java.util.function.Supplier; 7 | 8 | /** 9 | * Tracks data consistency between replica and main databases. 10 | */ 11 | @ThreadSafe 12 | public interface ReplicaConsistency { 13 | 14 | /** 15 | * Informs that {@code main} received an UPDATE, INSERT or DELETE or transaction commit 16 | * when in a transaction. 17 | * 18 | * @param main connects to the main database 19 | */ 20 | void write(Connection main); 21 | 22 | /** 23 | * Invoked just before transaction commit. 24 | *

25 | * Notice: The method will not handle all writes. Writes done outside of a transaction 26 | * needs to be handled in `ReplicaConnection#write`. 27 | * 28 | * @param main connects to the main database 29 | */ 30 | default void preCommit(Connection main) { 31 | } 32 | 33 | /** 34 | * Judges if {@code replica} is ready to be queried. 35 | * 36 | * @param replica connects to the replica database 37 | * @return true if {@code replica} is consistent with main 38 | */ 39 | boolean isConsistent(Supplier replica); 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/atlassian/db/replica/spi/SuppliedCache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.spi; 2 | 3 | import com.atlassian.db.replica.internal.util.ThreadSafe; 4 | 5 | import java.util.Optional; 6 | import java.util.function.Supplier; 7 | 8 | @ThreadSafe 9 | public interface SuppliedCache { 10 | /** 11 | * @param supplier used to populate the value. It must always return a value, never null. 12 | * @return T last remembered value or empty 13 | */ 14 | Optional get(Supplier supplier); 15 | 16 | /** 17 | * @return the cached value 18 | */ 19 | Optional get(); 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/AuroraClusterMock.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import com.google.common.collect.Sets; 4 | import org.h2.tools.SimpleResultSet; 5 | import org.mockito.Mockito; 6 | import org.mockito.invocation.InvocationOnMock; 7 | import org.mockito.stubbing.Answer; 8 | 9 | import java.sql.Connection; 10 | import java.sql.DriverManager; 11 | import java.sql.PreparedStatement; 12 | import java.sql.ResultSet; 13 | import java.sql.SQLException; 14 | import java.sql.Statement; 15 | import java.sql.Types; 16 | import java.util.Objects; 17 | import java.util.Set; 18 | import java.util.UUID; 19 | import java.util.concurrent.atomic.AtomicInteger; 20 | 21 | import static org.mockito.ArgumentMatchers.eq; 22 | import static org.mockito.Mockito.mock; 23 | import static org.mockito.Mockito.when; 24 | 25 | public class AuroraClusterMock { 26 | private final Connection connection; 27 | private static final Set replicas = Sets.newConcurrentHashSet(); 28 | private static final AtomicInteger counter = new AtomicInteger(); 29 | 30 | public AuroraClusterMock() throws SQLException { 31 | counter.set(0); 32 | replicas.clear(); 33 | this.connection = mock(Connection.class); 34 | final PreparedStatement preparedStatement = mock(PreparedStatement.class); 35 | when(this.connection.prepareStatement(eq("SELECT server_id, durable_lsn, current_read_lsn, feedback_xmin, " + 36 | "round(extract(milliseconds from (now()-last_update_timestamp))) as state_lag_in_msec, replica_lag_in_msec " + 37 | "FROM aurora_replica_status() " + 38 | "WHERE session_id != 'MASTER_SESSION_ID' and last_update_timestamp > NOW() - INTERVAL '5 minutes';"))) 39 | .thenReturn(preparedStatement); 40 | when(preparedStatement.executeQuery()).thenAnswer((Answer) invocation -> { 41 | return auroraGlobalDbInstanceStatus(); 42 | }); 43 | } 44 | 45 | public Connection getMainConnection() { 46 | return connection; 47 | } 48 | 49 | public AuroraClusterMock scaleUp() { 50 | replicas.add( 51 | new Node( 52 | "apg-global-db-rpo-mammothrw-elephantro-n" + counter.incrementAndGet(), 53 | UUID.randomUUID().toString() 54 | ) 55 | ); 56 | return this; 57 | } 58 | 59 | public AuroraClusterMock scaleDown() { 60 | replicas.remove(replicas.stream().findAny().orElse(null)); 61 | return this; 62 | } 63 | 64 | public static ResultSet auroraGlobalDbInstanceStatus() { 65 | SimpleResultSet rs = new SimpleResultSet(); 66 | rs.addColumn("SERVER_ID", Types.VARCHAR, 255, 0); 67 | rs.addColumn("SESSION_ID", Types.VARCHAR, 255, 0); 68 | rs.addColumn("REPLICA_LAG_IN_MSEC", Types.INTEGER,0,0); 69 | rs.addColumn("DURABLE_LSN", Types.INTEGER,0,0); 70 | rs.addColumn("CURRENT_READ_LSN", Types.INTEGER,0,0); 71 | rs.addColumn("FEEDBACK_XMIN", Types.INTEGER,0,0); 72 | rs.addColumn("STATE_LAG_IN_MSEC", Types.INTEGER,0,0); 73 | replicas.forEach(replica -> rs.addRow(replica.getServerId(), replica.getSessionId())); 74 | return rs; 75 | } 76 | 77 | private static class Node { 78 | private final String serverId; 79 | private final String sessionId; 80 | 81 | public Node(String serverId, String sessionId) { 82 | this.serverId = serverId; 83 | this.sessionId = sessionId; 84 | } 85 | 86 | public String getServerId() { 87 | return serverId; 88 | } 89 | 90 | public String getSessionId() { 91 | return sessionId; 92 | } 93 | 94 | @Override 95 | public boolean equals(Object o) { 96 | if (this == o) return true; 97 | if (o == null || getClass() != o.getClass()) return false; 98 | Node node = (Node) o; 99 | return Objects.equals(serverId, node.serverId) && Objects.equals(sessionId, node.sessionId); 100 | } 101 | 102 | @Override 103 | public int hashCode() { 104 | return Objects.hash(serverId, sessionId); 105 | } 106 | 107 | @Override 108 | public String toString() { 109 | return "Node{" + 110 | "serverId='" + serverId + '\'' + 111 | ", sessionId='" + sessionId + '\'' + 112 | '}'; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/CacheLoader.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import java.util.concurrent.CountDownLatch; 4 | import java.util.concurrent.ExecutorService; 5 | import java.util.concurrent.Executors; 6 | 7 | public class CacheLoader { 8 | private final ExecutorService executor = Executors.newCachedThreadPool(); 9 | 10 | public WaitingWork asyncPutWithSlowSupplier( 11 | ThrottledCache cache, 12 | long value 13 | ) throws InterruptedException { 14 | final CountDownLatch asyncThreadStarted = new CountDownLatch(1); 15 | final CountDownLatch asyncThreadFinished = new CountDownLatch(1); 16 | final CountDownLatch threadWaiting = new CountDownLatch(1); 17 | executor.submit(() -> { 18 | cache.get(() -> { 19 | asyncThreadStarted.countDown(); 20 | try { 21 | threadWaiting.await(); 22 | } catch (InterruptedException e) { 23 | throw new RuntimeException(e); 24 | } 25 | return value; 26 | }); 27 | asyncThreadFinished.countDown(); 28 | }); 29 | asyncThreadStarted.await(); 30 | return new WaitingWork(threadWaiting, asyncThreadFinished); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/ReplicaConsistencyMock.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import com.atlassian.db.replica.spi.ReplicaConsistency; 4 | 5 | import java.sql.Connection; 6 | import java.util.function.Supplier; 7 | 8 | public class ReplicaConsistencyMock implements ReplicaConsistency { 9 | private final boolean consistent; 10 | 11 | public ReplicaConsistencyMock(boolean consistent) { 12 | this.consistent = consistent; 13 | } 14 | 15 | @Override 16 | public void write(Connection main) { 17 | 18 | } 19 | 20 | @Override 21 | public boolean isConsistent(Supplier replica) { 22 | Connection connection = replica.get(); 23 | return consistent; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/ThrottledCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.ArrayList; 6 | import java.util.ConcurrentModificationException; 7 | import java.util.Optional; 8 | import java.util.Random; 9 | 10 | 11 | import static java.time.Duration.ofMillis; 12 | import static java.time.Duration.ofSeconds; 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.assertj.core.api.Assertions.assertThatNoException; 15 | import static org.assertj.core.api.Assertions.catchThrowable; 16 | 17 | public class ThrottledCacheTest { 18 | private final CacheLoader cacheLoader = new CacheLoader(); 19 | private final TickingClock clock = new TickingClock(); 20 | 21 | @Test 22 | public void staleWhileInvalidating() throws InterruptedException { 23 | ThrottledCache cache = ThrottledCache.builder(clock, ofSeconds(60)).build(); 24 | 25 | cache.get(() -> 1L); 26 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 27 | 28 | assertThat(cache.get(this::anyValue)).hasValue(1L); 29 | } 30 | 31 | @Test 32 | public void readsCachedValue() { 33 | ThrottledCache cache = ThrottledCache.builder(clock, ofSeconds(60)).build(); 34 | 35 | cache.get(() -> 1L); 36 | 37 | assertThat(cache.get()).hasValue(1L); 38 | } 39 | 40 | @Test 41 | public void serveLatestValue() { 42 | ThrottledCache cache = ThrottledCache.builder(clock, ofSeconds(60)).build(); 43 | cache.get(this::anyValue); 44 | cache.get(this::anyValue); 45 | 46 | assertThat(cache.get(() -> 4L)).hasValue(4L); 47 | } 48 | 49 | @Test 50 | public void noConcurrentInvalidation() throws InterruptedException { 51 | ThrottledCache cache = ThrottledCache.builder(clock, ofSeconds(60)).build(); 52 | 53 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 54 | cache.get(() -> { 55 | throw new ConcurrentModificationException("Concurrent invalidation is not allowed"); 56 | }); 57 | 58 | assertThatNoException(); 59 | } 60 | 61 | @Test 62 | public void emptyWhenLoadingFirstTime() throws InterruptedException { 63 | ThrottledCache cache = ThrottledCache.builder(clock, ofSeconds(60)).build(); 64 | 65 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 66 | 67 | assertThat(cache.get(this::anyValue)).isEmpty(); 68 | } 69 | 70 | @Test 71 | public void shouldSupplierBlockTheCacheUntilTimeout() throws InterruptedException { 72 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(1500)).build(); 73 | 74 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 75 | 76 | clock.tick(); 77 | 78 | assertThat(cache.get(this::anyValue)).isEmpty(); 79 | } 80 | 81 | @Test 82 | public void shouldntSupplierBlockTheCacheForever() throws InterruptedException { 83 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(1500)).build(); 84 | 85 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 86 | 87 | clock.tick(); 88 | clock.tick(); 89 | 90 | assertThat(cache.get(this::anyValue)).isNotEmpty(); 91 | 92 | } 93 | 94 | @Test 95 | public void shouldNotRobbedThreadReleaseLock() throws InterruptedException { 96 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(1500)).build(); 97 | 98 | final WaitingWork firstThreadWork = cacheLoader.asyncPutWithSlowSupplier(cache, 1); 99 | clock.tick(); 100 | clock.tick(); 101 | cacheLoader.asyncPutWithSlowSupplier(cache, 2); 102 | firstThreadWork.finish(); 103 | 104 | final Throwable throwable = catchThrowable(() -> cache.get(() -> { 105 | throw new ConcurrentModificationException("Concurrent invalidation is not allowed"); 106 | })); 107 | 108 | assertThat(throwable).doesNotThrowAnyException(); 109 | } 110 | 111 | @Test 112 | public void shouldntRobbedThreadUpdateValue() throws InterruptedException { 113 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(1500)).build(); 114 | 115 | final WaitingWork firstThreadWork = cacheLoader.asyncPutWithSlowSupplier(cache, 1); 116 | clock.tick(); 117 | clock.tick(); 118 | cache.get(() -> 2L); 119 | firstThreadWork.finish(); 120 | assertThat(cache.get()).isEqualTo(Optional.of(2L)); 121 | } 122 | 123 | @Test 124 | public void shouldFailingSupplierReleaseTheLock() { 125 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(1500)).build(); 126 | 127 | cache.get(() -> 1L); 128 | final Throwable throwable = catchThrowable(() -> { 129 | cache.get(() -> { 130 | throw new RuntimeException(); 131 | }); 132 | }); 133 | 134 | assertThat(throwable).isNotNull(); 135 | assertThat(cache.get()).isEqualTo(Optional.of(1L)); 136 | assertThat(cache.get(() -> 2L)).isEqualTo(Optional.of(2L)); 137 | } 138 | 139 | @Test 140 | public void shouldTimeoutLockMultipleTimes() throws InterruptedException { 141 | ThrottledCache cache = ThrottledCache.builder(clock, ofMillis(500)).build(); 142 | 143 | final ArrayList waitingWorks = new ArrayList<>(); 144 | for (int i = 0; i < 32; i++) { 145 | final WaitingWork waitingWork = cacheLoader.asyncPutWithSlowSupplier(cache, i); 146 | waitingWorks.add(waitingWork); 147 | clock.tick(); 148 | } 149 | waitingWorks.forEach(WaitingWork::finish); 150 | 151 | assertThat(cache.get()).isEqualTo(Optional.of(31L)); 152 | } 153 | 154 | private Long anyValue() { 155 | return new Random().nextLong(); 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/ThrottledSequenceCacheTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.ArrayList; 6 | import java.util.ConcurrentModificationException; 7 | import java.util.List; 8 | import java.util.Optional; 9 | import java.util.Random; 10 | 11 | import static java.time.Duration.ofMillis; 12 | import static java.time.Duration.ofSeconds; 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.assertj.core.api.Assertions.assertThatNoException; 15 | import static org.assertj.core.api.Assertions.catchThrowable; 16 | 17 | public class ThrottledSequenceCacheTest { 18 | private final CacheLoader cacheLoader = new CacheLoader(); 19 | 20 | private final TickingClock clock = new TickingClock(); 21 | 22 | @Test 23 | public void staleWhileInvalidating() throws InterruptedException { 24 | ThrottledCache cache = ThrottledCache.builder( 25 | clock, 26 | ofSeconds(60) 27 | ).sequenceCache(Long::compare).build(); 28 | 29 | cache.get(() -> 1L); 30 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 31 | 32 | assertThat(cache.get(this::anyValue)).hasValue(1L); 33 | } 34 | 35 | @Test 36 | public void shouldAllowOnlyToIncreaseTheCachedValue() throws InterruptedException { 37 | ThrottledCache cache = ThrottledCache.builder( 38 | clock, 39 | ofSeconds(60) 40 | ).sequenceCache(Long::compare).build(); 41 | 42 | cache.get(() -> 1L); 43 | cache.get(() -> 2L); 44 | cache.get(() -> 3L); 45 | cache.get(() -> 2L); 46 | 47 | assertThat(cache.get(() -> 1L)).hasValue(3L); 48 | } 49 | 50 | @Test 51 | public void readsCachedValue() { 52 | ThrottledCache cache = ThrottledCache.builder( 53 | clock, 54 | ofSeconds(60) 55 | ).sequenceCache(Long::compare).build(); 56 | 57 | cache.get(() -> 1L); 58 | 59 | assertThat(cache.get()).hasValue(1L); 60 | } 61 | 62 | @Test 63 | public void serveLatestValue() { 64 | ThrottledCache cache = ThrottledCache.builder( 65 | clock, 66 | ofSeconds(60) 67 | ).sequenceCache(Long::compare).build(); 68 | cache.get(() -> 1L); 69 | cache.get(() -> 2L); 70 | 71 | assertThat(cache.get(() -> 4L)).hasValue(4L); 72 | } 73 | 74 | @Test 75 | public void noConcurrentInvalidation() throws InterruptedException { 76 | ThrottledCache cache = ThrottledCache.builder( 77 | clock, 78 | ofSeconds(60) 79 | ).sequenceCache(Long::compare).build(); 80 | 81 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 82 | cache.get(() -> { 83 | throw new ConcurrentModificationException("Concurrent invalidation is not allowed"); 84 | }); 85 | 86 | assertThatNoException(); 87 | } 88 | 89 | @Test 90 | public void emptyWhenLoadingFirstTime() throws InterruptedException { 91 | ThrottledCache cache = ThrottledCache.builder( 92 | clock, 93 | ofSeconds(60) 94 | ).sequenceCache(Long::compare).build(); 95 | 96 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 97 | 98 | assertThat(cache.get(this::anyValue)).isEmpty(); 99 | } 100 | 101 | @Test 102 | public void shouldSupplierBlockTheCacheUntilTimeout() throws InterruptedException { 103 | ThrottledCache cache = ThrottledCache.builder( 104 | clock, 105 | ofMillis(1500) 106 | ).sequenceCache(Long::compare).build(); 107 | 108 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 109 | 110 | clock.tick(); 111 | 112 | assertThat(cache.get(this::anyValue)).isEmpty(); 113 | } 114 | 115 | @Test 116 | public void shouldntSupplierBlockTheCacheForever() throws InterruptedException { 117 | ThrottledCache cache = ThrottledCache.builder( 118 | clock, 119 | ofMillis(1500) 120 | ).sequenceCache(Long::compare).build(); 121 | 122 | cacheLoader.asyncPutWithSlowSupplier(cache, anyValue()); 123 | 124 | clock.tick(); 125 | clock.tick(); 126 | 127 | assertThat(cache.get(this::anyValue)).isNotEmpty(); 128 | 129 | } 130 | 131 | @Test 132 | public void shouldNotRobbedThreadReleaseLock() throws InterruptedException { 133 | ThrottledCache cache = ThrottledCache.builder( 134 | clock, 135 | ofMillis(1500) 136 | ).sequenceCache(Long::compare).build(); 137 | 138 | final WaitingWork firstThreadWork = cacheLoader.asyncPutWithSlowSupplier(cache, 1); 139 | clock.tick(); 140 | clock.tick(); 141 | cacheLoader.asyncPutWithSlowSupplier(cache, 2); 142 | firstThreadWork.finish(); 143 | 144 | final Throwable throwable = catchThrowable(() -> cache.get(() -> { 145 | throw new ConcurrentModificationException("Concurrent invalidation is not allowed"); 146 | })); 147 | 148 | assertThat(throwable).doesNotThrowAnyException(); 149 | } 150 | 151 | @Test 152 | public void shouldntRobbedThreadUpdateValue() throws InterruptedException { 153 | ThrottledCache cache = ThrottledCache.builder( 154 | clock, 155 | ofMillis(1500) 156 | ).sequenceCache(Long::compare).build(); 157 | 158 | final WaitingWork firstThreadWork = cacheLoader.asyncPutWithSlowSupplier(cache, 1); 159 | clock.tick(); 160 | clock.tick(); 161 | cache.get(() -> 2L); 162 | firstThreadWork.finish(); 163 | assertThat(cache.get()).isEqualTo(Optional.of(2L)); 164 | } 165 | 166 | @Test 167 | public void shouldFailingSupplierReleaseTheLock() { 168 | ThrottledCache cache = ThrottledCache.builder( 169 | clock, 170 | ofMillis(1500) 171 | ).sequenceCache(Long::compare).build(); 172 | 173 | cache.get(() -> 1L); 174 | final Throwable throwable = catchThrowable(() -> { 175 | cache.get(() -> { 176 | throw new RuntimeException(); 177 | }); 178 | }); 179 | 180 | assertThat(throwable).isNotNull(); 181 | assertThat(cache.get()).isEqualTo(Optional.of(1L)); 182 | assertThat(cache.get(() -> 2L)).isEqualTo(Optional.of(2L)); 183 | } 184 | 185 | @Test 186 | public void shouldUpdateValuesWhenCacheLoadSlowerThanTimeout() throws InterruptedException { 187 | ThrottledCache cache = ThrottledCache.builder( 188 | clock, 189 | ofMillis(500) 190 | ).sequenceCache(Long::compare).build(); 191 | 192 | final List waitingWorks = new ArrayList<>(); 193 | for (int i = 0; i < 32; i++) { 194 | final WaitingWork waitingWork = cacheLoader.asyncPutWithSlowSupplier(cache, i); 195 | waitingWorks.add(waitingWork); 196 | clock.tick(); 197 | } 198 | for (int i = 0; i < 32; i++) { 199 | waitingWorks.get(i).finish(); 200 | assertThat(cache.get()).isEqualTo(Optional.of((long) i)); 201 | } 202 | } 203 | 204 | private Long anyValue() { 205 | return new Random().nextLong(); 206 | } 207 | 208 | } 209 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/TickingClock.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import org.apache.commons.lang.NotImplementedException; 4 | 5 | import java.time.Clock; 6 | import java.time.Instant; 7 | import java.time.ZoneId; 8 | 9 | import static java.time.Duration.ofSeconds; 10 | 11 | class TickingClock extends Clock { 12 | private Instant instant = Instant.now(); 13 | 14 | public void tick() { 15 | instant = instant.plus(ofSeconds(1)); 16 | } 17 | 18 | @Override 19 | public ZoneId getZone() { 20 | throw new NotImplementedException(); 21 | } 22 | 23 | @Override 24 | public Clock withZone(ZoneId zone) { 25 | throw new NotImplementedException(); 26 | } 27 | 28 | @Override 29 | public Instant instant() { 30 | return instant; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/WaitingWork.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api; 2 | 3 | import java.util.concurrent.CountDownLatch; 4 | 5 | final class WaitingWork { 6 | private final CountDownLatch threadWaiting; 7 | private final CountDownLatch asyncThreadFinished; 8 | 9 | WaitingWork(CountDownLatch threadWaiting, CountDownLatch asyncThreadFinished) { 10 | this.threadWaiting = threadWaiting; 11 | this.asyncThreadFinished = asyncThreadFinished; 12 | } 13 | 14 | public void finish() { 15 | threadWaiting.countDown(); 16 | try { 17 | asyncThreadFinished.await(); 18 | } catch (InterruptedException e) { 19 | throw new RuntimeException(e); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/circuitbreaker/TestCircuitBreaker.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.circuitbreaker; 2 | 3 | import com.atlassian.db.replica.api.DualConnection; 4 | import com.atlassian.db.replica.api.mocks.ConnectionProviderMock; 5 | import com.atlassian.db.replica.internal.ReadReplicaUnsupportedOperationException; 6 | import com.atlassian.db.replica.internal.circuitbreaker.BreakOnNotSupportedOperations; 7 | import org.junit.jupiter.api.AfterEach; 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.sql.Connection; 11 | import java.sql.SQLException; 12 | 13 | import static com.atlassian.db.replica.api.Queries.SIMPLE_QUERY; 14 | import static com.atlassian.db.replica.api.mocks.CircularConsistency.permanentConsistency; 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.assertj.core.api.Assertions.catchThrowable; 17 | 18 | public class TestCircuitBreaker { 19 | 20 | @AfterEach 21 | public void after() { 22 | BreakOnNotSupportedOperations.reset(); 23 | } 24 | 25 | @Test 26 | public void shouldPropagateUnimplementedMethodCall() throws SQLException { 27 | final Connection connection = DualConnection.builder( 28 | new ConnectionProviderMock(), 29 | permanentConsistency().build() 30 | ).build(); 31 | Throwable firstCall = catchThrowable(() -> connection.prepareStatement(SIMPLE_QUERY).getMetaData()); 32 | final ConnectionProviderMock connectionProvider = new ConnectionProviderMock(); 33 | final Connection newConnection = DualConnection.builder(connectionProvider, permanentConsistency().build()).build(); 34 | Throwable secondCall = catchThrowable(() -> newConnection.prepareStatement(SIMPLE_QUERY).getMetaData()); 35 | 36 | assertThat(connectionProvider.getPreparedStatements()).isEmpty(); 37 | assertThat(firstCall).isInstanceOf(ReadReplicaUnsupportedOperationException.class); 38 | assertThat(secondCall).isInstanceOf(ReadReplicaUnsupportedOperationException.class); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/mocks/CircularConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.mocks; 2 | 3 | import com.atlassian.db.replica.spi.ReplicaConsistency; 4 | import com.google.common.collect.ImmutableList; 5 | 6 | import java.sql.Connection; 7 | import java.util.List; 8 | import java.util.concurrent.atomic.AtomicInteger; 9 | import java.util.function.Supplier; 10 | 11 | public class CircularConsistency implements ReplicaConsistency { 12 | private final List consistency; 13 | private final boolean ignoreSupplier; 14 | private final AtomicInteger counter = new AtomicInteger(); 15 | 16 | private CircularConsistency(List consistency, boolean ignoreSupplier) { 17 | this.consistency = consistency; 18 | this.ignoreSupplier = ignoreSupplier; 19 | } 20 | 21 | @Override 22 | public void write(Connection main) { 23 | 24 | } 25 | 26 | @Override 27 | public boolean isConsistent(Supplier replica) { 28 | if (!this.ignoreSupplier) { 29 | replica.get(); 30 | } 31 | return consistency.get(counter.getAndIncrement() % consistency.size()); 32 | } 33 | 34 | public static Builder permanentConsistency() { 35 | return new Builder(ImmutableList.of(true)); 36 | } 37 | 38 | public static Builder permanentInconsistency() { 39 | return new Builder(ImmutableList.of(false)); 40 | } 41 | 42 | public static class Builder { 43 | private final List consistency; 44 | private boolean ignoreSupplier = false; 45 | 46 | public Builder(List consistency) { 47 | this.consistency = consistency; 48 | } 49 | 50 | public Builder ignoreSupplier(boolean ignoreSupplier) { 51 | this.ignoreSupplier = ignoreSupplier; 52 | return this; 53 | } 54 | 55 | public ReplicaConsistency build() { 56 | return new CircularConsistency(consistency, ignoreSupplier); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/mocks/MockLogger.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.mocks; 2 | 3 | import com.atlassian.db.replica.spi.Logger; 4 | 5 | import java.util.LinkedList; 6 | import java.util.List; 7 | import java.util.stream.Collectors; 8 | 9 | import static java.util.stream.Collectors.joining; 10 | 11 | public class MockLogger implements Logger { 12 | private final List messages = new LinkedList<>(); 13 | 14 | public void debug(String message) { 15 | messages.add(message); 16 | } 17 | 18 | public void debug(String message, Throwable t) { 19 | messages.add(message); 20 | } 21 | 22 | public void info(String message) { 23 | messages.add(message); 24 | } 25 | 26 | public void info(String message, Throwable t) { 27 | messages.add(message); 28 | } 29 | 30 | public void warn(String message) { 31 | messages.add(message); 32 | } 33 | 34 | public void warn(String message, Throwable t) { 35 | messages.add(message); 36 | } 37 | 38 | public void error(String message) { 39 | messages.add(message); 40 | } 41 | 42 | public void error(String message, Throwable t) { 43 | messages.add(message); 44 | } 45 | 46 | public List getMessages() { 47 | return messages; 48 | } 49 | 50 | @Override 51 | public String toString() { 52 | return messages.stream().map(message -> message + "\n").collect(joining()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/mocks/NoOpConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.mocks; 2 | 3 | import com.atlassian.db.replica.spi.ConnectionProvider; 4 | 5 | import java.sql.Connection; 6 | 7 | public class NoOpConnectionProvider implements ConnectionProvider { 8 | 9 | @Override 10 | public boolean isReplicaAvailable() { 11 | return true; 12 | } 13 | 14 | @Override 15 | public Connection getMainConnection() { 16 | return new NoOpConnection(); 17 | } 18 | 19 | @Override 20 | public Connection getReplicaConnection() { 21 | return new NoOpConnection(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/mocks/ReadOnlyAwareConnection.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.mocks; 2 | 3 | import java.sql.Connection; 4 | 5 | public abstract class ReadOnlyAwareConnection implements Connection { 6 | private boolean isReadOnly; 7 | 8 | @Override 9 | public void setReadOnly(boolean readOnly) { 10 | this.isReadOnly = readOnly; 11 | } 12 | 13 | @Override 14 | public boolean isReadOnly() { 15 | return isReadOnly; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/api/mocks/SingleConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.api.mocks; 2 | 3 | import com.atlassian.db.replica.spi.ConnectionProvider; 4 | 5 | import java.sql.Connection; 6 | 7 | public class SingleConnectionProvider implements ConnectionProvider { 8 | private Connection connection; 9 | 10 | public SingleConnectionProvider(Connection connection) { 11 | this.connection = connection; 12 | } 13 | 14 | @Override 15 | public boolean isReplicaAvailable() { 16 | return true; 17 | } 18 | 19 | @Override 20 | public Connection getMainConnection() { 21 | return connection; 22 | } 23 | 24 | @Override 25 | public Connection getReplicaConnection() { 26 | return connection; 27 | } 28 | 29 | public void setConnection(Connection connection) { 30 | this.connection = connection; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/AuroraPostgresLsnReplicaConsistencyTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.api.AuroraPostgresLsnReplicaConsistency; 4 | import com.atlassian.db.replica.internal.util.ConnectionSupplier; 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.sql.Connection; 9 | import java.sql.PreparedStatement; 10 | import java.sql.ResultSet; 11 | import java.sql.SQLException; 12 | 13 | import static org.assertj.core.api.Assertions.*; 14 | import static org.mockito.ArgumentMatchers.anyString; 15 | import static org.mockito.Mockito.mock; 16 | import static org.mockito.Mockito.when; 17 | 18 | public class AuroraPostgresLsnReplicaConsistencyTest { 19 | 20 | private static final long LAST_WRITE_LSN = 8679792506L; 21 | 22 | private VolatileCache lastWriteCache; 23 | private AuroraPostgresLsnReplicaConsistency consistency; 24 | private Connection main; 25 | private Connection replica; 26 | 27 | @BeforeEach 28 | public void setUp() { 29 | lastWriteCache = new VolatileCache<>(); 30 | consistency = new AuroraPostgresLsnReplicaConsistency.Builder() 31 | .cacheLastWrite(lastWriteCache) 32 | .build(); 33 | 34 | main = mock(Connection.class); 35 | replica = mock(Connection.class); 36 | } 37 | 38 | @Test 39 | public void shouldThrowRuntimeExceptionWhenLastWriteLsnFails() throws Exception { 40 | mockLsnFetchingFailure(main); 41 | 42 | assertThatThrownBy(() -> consistency.write(main)).isInstanceOf(RuntimeException.class); 43 | } 44 | 45 | @Test 46 | public void shouldBeInconsistentWhenLastWriteLsnUnknown() { 47 | lastWriteCache.reset(); 48 | 49 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 50 | 51 | assertThat(isConsistent).isFalse(); 52 | } 53 | 54 | @Test 55 | public void shouldBeInconsistentWhenReplicaLsnFails() throws Exception { 56 | mockLsnFetching(main, LAST_WRITE_LSN); 57 | mockLsnFetchingFailure(replica); 58 | consistency.write(main); 59 | 60 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 61 | 62 | assertThat(isConsistent).isFalse(); 63 | } 64 | 65 | @Test 66 | public void shouldBeInconsistentWhenReplicaIsBehind() throws Exception { 67 | mockLsnFetching(main, LAST_WRITE_LSN); 68 | mockLsnFetching(replica, LAST_WRITE_LSN - 1); 69 | consistency.write(main); 70 | 71 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 72 | 73 | assertThat(isConsistent).isFalse(); 74 | } 75 | 76 | @Test 77 | public void shouldBeConsistentWhenReplicaIsAhead() throws Exception { 78 | mockLsnFetching(main, LAST_WRITE_LSN); 79 | mockLsnFetching(replica, LAST_WRITE_LSN + 1); 80 | consistency.write(main); 81 | 82 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 83 | 84 | assertThat(isConsistent).isTrue(); 85 | } 86 | 87 | @Test 88 | public void shouldBeConsistentWhenReplicaCaughtUp() throws Exception { 89 | mockLsnFetching(main, LAST_WRITE_LSN); 90 | mockLsnFetching(replica, LAST_WRITE_LSN); 91 | consistency.write(main); 92 | 93 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 94 | 95 | assertThat(isConsistent).isTrue(); 96 | } 97 | 98 | private void mockLsnFetching(Connection connection, long lsn) throws SQLException { 99 | PreparedStatement preparedStatement = mock(PreparedStatement.class); 100 | when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); 101 | ResultSet resultSet = mock(ResultSet.class); 102 | when(preparedStatement.executeQuery()).thenReturn(resultSet); 103 | when(resultSet.getLong("lsn")).thenReturn(lsn); 104 | } 105 | 106 | private void mockLsnFetchingFailure(Connection connection) throws SQLException { 107 | PreparedStatement preparedStatement = mock(PreparedStatement.class); 108 | when(connection.prepareStatement(anyString())).thenReturn(preparedStatement); 109 | when(preparedStatement.executeQuery()).thenThrow(new SQLException()); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/DefaultReplicaConnectionPerUrlProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.api.jdbc.JdbcUrl; 4 | import com.atlassian.db.replica.spi.ReplicaConnectionPerUrlProvider; 5 | import com.atlassian.db.replica.spi.ReplicaConnectionProvider; 6 | 7 | import java.sql.DriverManager; 8 | 9 | public class DefaultReplicaConnectionPerUrlProvider implements ReplicaConnectionPerUrlProvider { 10 | private final String username; 11 | private final String password; 12 | 13 | public DefaultReplicaConnectionPerUrlProvider(String username, String password) { 14 | this.username = username; 15 | this.password = password; 16 | } 17 | 18 | @Override 19 | public ReplicaConnectionProvider getReplicaConnectionProvider(JdbcUrl replicaUrl) { 20 | return () -> DriverManager.getConnection(replicaUrl.toString(), username, password); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/LazyReferenceTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.concurrent.CountDownLatch; 6 | import java.util.concurrent.ExecutorService; 7 | import java.util.concurrent.Executors; 8 | import java.util.concurrent.atomic.AtomicInteger; 9 | 10 | import static org.assertj.core.api.Assertions.assertThat; 11 | 12 | 13 | public class LazyReferenceTest { 14 | 15 | @Test 16 | public void shouldCreateValueOnce() { 17 | final CountingReference countingReference = new CountingReference(); 18 | 19 | countingReference.get(); 20 | countingReference.get(); 21 | countingReference.get(); 22 | 23 | assertThat(countingReference.getCounter()).isEqualTo(1); 24 | } 25 | 26 | @Test 27 | public void shouldCreateValueOnceWhileAccessedConcurrently() throws InterruptedException { 28 | final CountingReference countingReference = new CountingReference(); 29 | final int threads = 128; 30 | final ExecutorService executor = Executors.newFixedThreadPool(threads); 31 | final CountDownLatch start = new CountDownLatch(threads); 32 | final CountDownLatch end = new CountDownLatch(threads); 33 | for (int i = 0; i < threads; i++) { 34 | executor.submit(() -> fetchReference(countingReference, start, end)); 35 | } 36 | end.await(); 37 | 38 | assertThat(countingReference.getCounter()).isEqualTo(1); 39 | executor.shutdown(); 40 | } 41 | 42 | private void fetchReference(CountingReference countingReference, CountDownLatch start, CountDownLatch end) { 43 | start.countDown(); 44 | try { 45 | start.await(); 46 | } catch (InterruptedException e) { 47 | throw new RuntimeException(e); 48 | } 49 | countingReference.get(); 50 | end.countDown(); 51 | } 52 | 53 | 54 | private static class CountingReference extends LazyReference { 55 | final AtomicInteger counter = new AtomicInteger(); 56 | 57 | private CountingReference() { 58 | super(); 59 | } 60 | 61 | @Override 62 | protected Integer create() { 63 | return counter.incrementAndGet(); 64 | } 65 | 66 | public int getCounter() { 67 | return counter.get(); 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/LsnReplicaConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.internal.util.ThreadSafe; 4 | import com.atlassian.db.replica.spi.*; 5 | import org.postgresql.replication.LogSequenceNumber; 6 | 7 | import java.sql.Connection; 8 | import java.sql.PreparedStatement; 9 | import java.sql.ResultSet; 10 | import java.util.*; 11 | import java.util.function.Supplier; 12 | 13 | /** 14 | * [LSN] means "log sequence number". It points to a place in the PostgreSQL write-ahead log. 15 | * Each replica updates its WAL via recovery. Recovery-time LSN can be queried by recovery info. 16 | * When a replica is caught up, it's no longer in recovery. Non-recovery LSN can be queried by backup control. 17 | * 18 | * @see LSN 19 | * @see recovery info 20 | * @see backup control 21 | *

22 | * It's a DB specific implementation used in integration tests. 23 | */ 24 | @ThreadSafe 25 | public class LsnReplicaConsistency implements ReplicaConsistency { 26 | 27 | private final Cache lastWrite = Cache.cacheMonotonicValuesInMemory(); 28 | 29 | @Override 30 | public void write(Connection main) { 31 | try { 32 | lastWrite.put(queryLsn(main)); 33 | } catch (Exception e) { 34 | //TODO: log warning 35 | lastWrite.reset(); 36 | } 37 | } 38 | 39 | @Override 40 | public boolean isConsistent(Supplier replica) { 41 | Optional maybeLastWrite = lastWrite.get(); 42 | if (!maybeLastWrite.isPresent()) { 43 | return false; 44 | } 45 | LogSequenceNumber lastRefresh; 46 | try { 47 | lastRefresh = queryLsn(replica.get()); 48 | } catch (Exception e) { 49 | //TODO: log warning 50 | return false; 51 | } 52 | return lastRefresh.asLong() >= maybeLastWrite.get().asLong(); 53 | } 54 | 55 | private LogSequenceNumber queryLsn(Connection connection) throws Exception { 56 | try ( 57 | PreparedStatement query = prepareQuery(connection); 58 | ResultSet results = query.executeQuery() 59 | ) { 60 | results.next(); 61 | String lsn = results.getString("lsn"); 62 | return LogSequenceNumber.valueOf(lsn); 63 | } 64 | } 65 | 66 | private PreparedStatement prepareQuery(Connection connection) throws Exception { 67 | return connection.prepareStatement( 68 | "SELECT\n" + 69 | "CASE WHEN pg_is_in_recovery()\n" + 70 | " THEN pg_last_xlog_replay_location()\n" + 71 | " ELSE pg_current_xlog_location()\n" + 72 | "END AS lsn;" 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/LsnReplicaConsistencyTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.internal.util.ConnectionSupplier; 4 | import com.atlassian.db.replica.spi.ReplicaConsistency; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.sql.Connection; 8 | import java.sql.PreparedStatement; 9 | import java.sql.ResultSet; 10 | import java.sql.SQLException; 11 | import java.util.function.Supplier; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | import static org.mockito.ArgumentMatchers.anyString; 15 | import static org.mockito.Mockito.mock; 16 | import static org.mockito.Mockito.when; 17 | 18 | public class LsnReplicaConsistencyTest { 19 | 20 | @Test 21 | public void shouldBeConsistentIfReplicaGivesTheSameLsnAsMain() throws SQLException { 22 | final ReplicaConsistency consistency = new LsnReplicaConsistency(); 23 | consistency.write(getConnection("16/3002D50")); 24 | 25 | final boolean isConsistent = consistency.isConsistent(getConnectionSupplier("16/3002D50")); 26 | 27 | assertThat(isConsistent).isTrue(); 28 | } 29 | 30 | @Test 31 | public void shouldNotBeConsistentAfterFailedWrite() throws SQLException { 32 | final ReplicaConsistency consistency = new LsnReplicaConsistency(); 33 | final Connection main = mock(Connection.class); 34 | when(main.prepareStatement(anyString())).thenThrow(new SQLException("Main connection fails")); 35 | 36 | consistency.write(main); 37 | 38 | final boolean isConsistent = consistency.isConsistent(getConnectionSupplier("16/3002D50")); 39 | 40 | assertThat(isConsistent).isFalse(); 41 | } 42 | 43 | @Test 44 | public void shouldRecoverAfterFailedWrite() throws SQLException { 45 | final ReplicaConsistency consistency = new LsnReplicaConsistency(); 46 | final Connection main = mock(Connection.class); 47 | when(main.prepareStatement(anyString())).thenThrow(new SQLException("Main connection fails")); 48 | 49 | consistency.write(main); 50 | consistency.write(getConnection("16/3002D50")); 51 | 52 | final boolean isConsistent = consistency.isConsistent(getConnectionSupplier("16/3002D50")); 53 | 54 | assertThat(isConsistent).isTrue(); 55 | } 56 | 57 | @Test 58 | public void shouldNotBeConsistentBeforeTheFirstWrite() { 59 | final ReplicaConsistency consistency = new LsnReplicaConsistency(); 60 | final Supplier replica = mock(ConnectionSupplier.class); 61 | 62 | final boolean isConsistent = consistency.isConsistent(replica); 63 | 64 | assertThat(isConsistent).isFalse(); 65 | } 66 | 67 | @Test 68 | public void shouldNotBeConsistentIfReplicaConnectionFails() throws SQLException { 69 | final ReplicaConsistency consistency = new LsnReplicaConsistency(); 70 | final Connection mainConnection = getConnection("16/3002D50"); 71 | consistency.write(mainConnection); 72 | final Connection replica = mock(Connection.class); 73 | when(replica.prepareStatement(anyString())).thenThrow(new SQLException("Replica connection fails")); 74 | 75 | final boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 76 | 77 | assertThat(isConsistent).isFalse(); 78 | } 79 | 80 | private Supplier getConnectionSupplier(String lsn) throws SQLException { 81 | return new ConnectionSupplier(getConnection(lsn)); 82 | } 83 | 84 | private Connection getConnection(String lsn) throws SQLException { 85 | final Connection main = mock(Connection.class); 86 | final PreparedStatement preparedStatement = mock(PreparedStatement.class); 87 | final ResultSet resultSet = mock(ResultSet.class); 88 | when(main.prepareStatement(anyString())).thenReturn(preparedStatement); 89 | when(preparedStatement.executeQuery()).thenReturn(resultSet); 90 | when(resultSet.getString(anyString())).thenReturn(lsn); 91 | return main; 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/PessimisticPropagationConsistencyTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.api.PessimisticPropagationConsistency; 4 | import com.atlassian.db.replica.internal.util.ConnectionSupplier; 5 | import com.atlassian.db.replica.spi.ReplicaConsistency; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.threeten.extra.MutableClock; 9 | 10 | import java.sql.Connection; 11 | import java.time.Duration; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | public class PessimisticPropagationConsistencyTest { 16 | 17 | private PessimisticPropagationConsistency.Builder consistencyBuilder; 18 | private MutableClock clock; 19 | private Connection main; 20 | private Connection replica; 21 | 22 | @BeforeEach 23 | public void resetState() { 24 | clock = MutableClock.epochUTC(); 25 | consistencyBuilder = new PessimisticPropagationConsistency.Builder() 26 | .measureTime(clock) 27 | .cacheLastWrite(new VolatileCache<>()); 28 | main = null; 29 | replica = null; 30 | } 31 | 32 | @Test 33 | public void shouldBeConsistentAfterPropagation() { 34 | ReplicaConsistency consistency = consistencyBuilder 35 | .assumeMaxPropagation(Duration.ofMillis(200)) 36 | .build(); 37 | 38 | consistency.write(main); 39 | clock.add(Duration.ofMillis(300)); 40 | boolean consistent = consistency.isConsistent(new ConnectionSupplier(replica)); 41 | 42 | assertThat(consistent).isTrue(); 43 | } 44 | 45 | @Test 46 | public void shouldBeInconsistentBeforePropagation() { 47 | ReplicaConsistency consistency = consistencyBuilder 48 | .assumeMaxPropagation(Duration.ofMillis(200)) 49 | .build(); 50 | 51 | consistency.write(main); 52 | clock.add(Duration.ofMillis(50)); 53 | boolean consistent = consistency.isConsistent(new ConnectionSupplier(replica)); 54 | 55 | assertThat(consistent).isFalse(); 56 | } 57 | 58 | @Test 59 | public void shouldAssumeInconsistencyWhenUnknown() { 60 | ReplicaConsistency consistency = consistencyBuilder 61 | .assumeMaxPropagation(Duration.ofMillis(200)) 62 | .build(); 63 | 64 | clock.add(Duration.ofMillis(700)); 65 | boolean consistent = consistency.isConsistent(new ConnectionSupplier(replica)); 66 | 67 | assertThat(consistent).isFalse(); 68 | } 69 | 70 | @Test 71 | public void shouldNotAssumeInconsistentForeverWhenUnknown() { 72 | ReplicaConsistency consistency = consistencyBuilder 73 | .assumeMaxPropagation(Duration.ofMillis(200)) 74 | .build(); 75 | 76 | clock.add(Duration.ofMillis(700)); 77 | boolean isConsistent = consistency.isConsistent(new ConnectionSupplier(replica)); 78 | clock.add(Duration.ofMillis(700)); 79 | boolean isConsistentLater = consistency.isConsistent(new ConnectionSupplier(replica)); 80 | 81 | assertThat(isConsistent).isFalse(); 82 | assertThat(isConsistentLater).isTrue(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/VolatileCache.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal; 2 | 3 | import com.atlassian.db.replica.spi.*; 4 | 5 | import java.util.*; 6 | 7 | public class VolatileCache implements Cache { 8 | 9 | private volatile T value; 10 | 11 | @Override 12 | public Optional get() { 13 | return Optional.ofNullable(value); 14 | } 15 | 16 | @Override 17 | public void put(T value) { 18 | this.value = value; 19 | } 20 | 21 | @Override 22 | public void reset() { 23 | value = null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/aurora/AuroraEndpointTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.aurora; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | class AuroraEndpointTest { 8 | 9 | @Test 10 | void shouldCreateAuroraEndpointFromReaderEndpoint() { 11 | // when 12 | AuroraEndpoint endpoint = AuroraEndpoint.parse("arch-app-staging-1-001-lr.cm9o6ayveq1a.us-east-9.rds.amazonaws.com"); 13 | 14 | // then 15 | assertThat(endpoint.getServerId()).isEqualTo("arch-app-staging-1-001-lr"); 16 | assertThat(endpoint.getCluster()).isEqualTo(AuroraCluster.AuroraClusterBuilder.anAuroraCluster("cm9o6ayveq1a").build()); 17 | assertThat(endpoint.getDns()).isEqualTo(new RdsDns("us-east-9", "rds.amazonaws.com", null)); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/util/ConnectionSupplier.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.util; 2 | 3 | import java.sql.Connection; 4 | import java.util.function.Supplier; 5 | 6 | public class ConnectionSupplier implements Supplier { 7 | private final Connection connection; 8 | 9 | public ConnectionSupplier(Connection connection) { 10 | this.connection = connection; 11 | } 12 | 13 | @Override 14 | public Connection get() { 15 | return connection; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/internal/util/TestComparables.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.internal.util; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | public class TestComparables { 8 | 9 | @Test 10 | public void shouldReturnMaxValue() { 11 | final Integer max = Comparables.max(1, 2); 12 | 13 | assertThat(max).isEqualTo(2); 14 | } 15 | 16 | @Test 17 | public void shouldReturnEqualValue() { 18 | final Integer max = Comparables.max(5, 5); 19 | 20 | assertThat(max).isEqualTo(5); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/DualConnectionPerfIT.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it; 2 | 3 | import com.atlassian.db.replica.api.DualConnection; 4 | import com.atlassian.db.replica.api.mocks.NoOpConnectionProvider; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.sql.Connection; 8 | import java.sql.SQLException; 9 | import java.time.Duration; 10 | import java.time.Instant; 11 | 12 | import static com.atlassian.db.replica.api.Queries.LARGE_SQL_QUERY; 13 | import static com.atlassian.db.replica.api.mocks.CircularConsistency.permanentConsistency; 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | public class DualConnectionPerfIT { 17 | 18 | @Test 19 | public void shouldHaveAcceptableThruput() throws SQLException { 20 | final Connection connection = DualConnection 21 | .builder( 22 | new NoOpConnectionProvider(), 23 | permanentConsistency().build() 24 | ).build(); 25 | final int times = 100000000; 26 | 27 | final Duration duration = runBenchmark(connection, times); 28 | 29 | float thruputPerMillis = (float) times / duration.toMillis(); 30 | assertThat(thruputPerMillis) 31 | .as("thruput per ms") 32 | .isGreaterThan(2_200); 33 | } 34 | 35 | private Duration runBenchmark(Connection connection, int times) throws SQLException { 36 | final Instant start = Instant.now(); 37 | int hashCode = 0; 38 | for (int i = 0; i < times; i++) { 39 | hashCode += connection.prepareStatement(LARGE_SQL_QUERY).executeQuery().hashCode(); 40 | } 41 | System.out.println("I really need that number. JIT gods don't kill my code paths. " + hashCode); 42 | return Duration.between(start, Instant.now()); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/PostgresConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it; 2 | 3 | import com.atlassian.db.replica.spi.ConnectionProvider; 4 | import com.github.dockerjava.api.DockerClient; 5 | import com.github.dockerjava.api.command.CreateContainerResponse; 6 | import com.github.dockerjava.api.model.ExposedPort; 7 | import com.github.dockerjava.api.model.Link; 8 | import com.github.dockerjava.api.model.PortBinding; 9 | import com.github.dockerjava.core.DefaultDockerClientConfig; 10 | import com.github.dockerjava.core.DockerClientImpl; 11 | import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; 12 | import com.github.dockerjava.transport.DockerHttpClient; 13 | import com.google.common.collect.ImmutableList; 14 | 15 | import java.sql.Connection; 16 | import java.sql.DriverManager; 17 | import java.time.Duration; 18 | import java.util.Properties; 19 | 20 | public class PostgresConnectionProvider implements ConnectionProvider, AutoCloseable { 21 | final DefaultDockerClientConfig config = DefaultDockerClientConfig 22 | .createDefaultConfigBuilder() 23 | .build(); 24 | final DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder() 25 | .dockerHost(config.getDockerHost()) 26 | .sslConfig(config.getSSLConfig()) 27 | .build(); 28 | final DockerClient dockerClient = DockerClientImpl.getInstance(config, httpClient); 29 | boolean isInitialized = false; 30 | 31 | @Override 32 | public boolean isReplicaAvailable() { 33 | return true; 34 | } 35 | 36 | @Override 37 | public Connection getMainConnection() { 38 | initialize(); 39 | final String url = "jdbc:postgresql://localhost:5440/jira"; 40 | Properties props = new Properties(); 41 | props.setProperty("user", "postgres"); 42 | props.setProperty("password", "jira"); 43 | try { 44 | return DriverManager.getConnection(url, props); 45 | } catch (Exception e) { 46 | throw new RuntimeException(e); 47 | } 48 | } 49 | 50 | @Override 51 | public Connection getReplicaConnection() { 52 | initialize(); 53 | final String url = "jdbc:postgresql://localhost:5450/jira"; 54 | Properties props = new Properties(); 55 | props.setProperty("user", "postgres"); 56 | props.setProperty("password", "jira"); 57 | try { 58 | return DriverManager.getConnection(url, props); 59 | } catch (Exception e) { 60 | throw new RuntimeException(e); 61 | } 62 | } 63 | 64 | private synchronized void initialize() { 65 | if (isInitialized) { 66 | return; 67 | } 68 | isInitialized = true; 69 | cleanUp(); 70 | pullImage(); 71 | startMaster(); 72 | startReplica(); 73 | } 74 | 75 | private void pullImage() { 76 | try { 77 | dockerClient.pullImageCmd("bitnami/postgresql:13").start().awaitCompletion(); 78 | } catch (InterruptedException e) { 79 | throw new RuntimeException(e); 80 | } 81 | } 82 | 83 | @Override 84 | public void close() { 85 | cleanUp(); 86 | } 87 | 88 | private void startReplica() { 89 | final CreateContainerResponse replicaCreate = dockerClient.createContainerCmd("bitnami/postgresql:13") 90 | .withEnv( 91 | "POSTGRESQL_REPLICATION_MODE=slave", 92 | "POSTGRESQL_MASTER_HOST=master", 93 | "POSTGRESQL_MASTER_PORT_NUMBER=5432", 94 | "POSTGRESQL_REPLICATION_USER=postgres", 95 | "POSTGRESQL_REPLICATION_PASSWORD=jira", 96 | "POSTGRESQL_PASSWORD=jira" 97 | ) 98 | .withExposedPorts(ExposedPort.tcp(5432)) 99 | .withPortBindings(PortBinding.parse("5450:5432")) 100 | .withName("db-replica-postgresql-replica") 101 | .withLinks(Link.parse("db-replica-postgresql-main:master")) 102 | .exec(); 103 | dockerClient 104 | .startContainerCmd(replicaCreate.getId()) 105 | .exec(); 106 | for (int i = 0; i < 10; i++) { 107 | try { 108 | getReplicaConnection().close(); 109 | } catch (Exception e) { 110 | try { 111 | Thread.sleep(Duration.ofSeconds(1).toMillis()); 112 | } catch (Exception ex) { 113 | throw new RuntimeException(e); 114 | } 115 | } 116 | } 117 | } 118 | 119 | private void startMaster() { 120 | final CreateContainerResponse masterCreate = dockerClient.createContainerCmd("bitnami/postgresql:13") 121 | .withEnv( 122 | "POSTGRESQL_REPLICATION_MODE=master", 123 | "POSTGRESQL_USERNAME=postgres", 124 | "POSTGRESQL_PASSWORD=jira", 125 | "POSTGRESQL_DATABASE=jira", 126 | "POSTGRESQL_REPLICATION_USER=postgres", 127 | "POSTGRESQL_REPLICATION_PASSWORD=jira" 128 | ).withExposedPorts(ExposedPort.tcp(5432)) 129 | .withPortBindings(PortBinding.parse("5440:5432")) 130 | .withName("db-replica-postgresql-main") 131 | .exec(); 132 | dockerClient 133 | .startContainerCmd(masterCreate.getId()) 134 | .exec(); 135 | for (int i = 0; i < 10; i++) { 136 | try { 137 | getMainConnection().close(); 138 | } catch (Exception e) { 139 | try { 140 | Thread.sleep(Duration.ofSeconds(1).toMillis()); 141 | } catch (Exception ex) { 142 | throw new RuntimeException(e); 143 | } 144 | } 145 | } 146 | } 147 | 148 | private void cleanUp() { 149 | dockerClient 150 | .listContainersCmd() 151 | .withShowAll(true) 152 | .withNameFilter( 153 | ImmutableList.of( 154 | "db-replica-postgresql-main", 155 | "db-replica-postgresql-replica" 156 | ) 157 | ) 158 | .exec() 159 | .forEach(container -> { 160 | try { 161 | dockerClient.stopContainerCmd(container.getId()).exec(); 162 | } catch (Exception e) { 163 | // it's probably already stopped 164 | } 165 | dockerClient.removeContainerCmd(container.getId()).exec(); 166 | }); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/ReplicaStatementIT.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it; 2 | 3 | import com.atlassian.db.replica.api.DualConnection; 4 | import com.atlassian.db.replica.internal.LsnReplicaConsistency; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import java.sql.Connection; 8 | import java.sql.SQLException; 9 | import java.sql.SQLFeatureNotSupportedException; 10 | import java.sql.Statement; 11 | 12 | import static java.sql.ResultSet.CONCUR_UPDATABLE; 13 | import static java.sql.ResultSet.FETCH_REVERSE; 14 | import static java.sql.ResultSet.TYPE_SCROLL_SENSITIVE; 15 | import static java.sql.Statement.RETURN_GENERATED_KEYS; 16 | import static org.assertj.core.api.Assertions.assertThat; 17 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 18 | 19 | public class ReplicaStatementIT { 20 | 21 | @Test 22 | public void shouldImplementAllStatementMethods() throws SQLException { 23 | try (PostgresConnectionProvider connectionProvider = new PostgresConnectionProvider()) { 24 | final Connection connection = DualConnection.builder( 25 | connectionProvider, 26 | new LsnReplicaConsistency() 27 | ).build(); 28 | final Statement statement = connection.createStatement(); 29 | statement.executeQuery("SELECT 1;"); 30 | statement.executeUpdate("CREATE SEQUENCE mysequence START 101;"); 31 | statement.setMaxFieldSize(300); 32 | assertThat(statement.getMaxFieldSize()).isEqualTo(300); 33 | statement.setMaxRows(301); 34 | assertThat(statement.getMaxRows()).isEqualTo(301); 35 | statement.setEscapeProcessing(false); 36 | statement.setEscapeProcessing(true); 37 | statement.setQueryTimeout(33); 38 | assertThat(statement.getQueryTimeout()).isEqualTo(33); 39 | statement.getWarnings(); 40 | statement.clearWarnings(); 41 | statement.setCursorName("alosaf"); 42 | statement.execute("SELECT 1;"); 43 | statement.getResultSet(); 44 | statement.getUpdateCount(); 45 | statement.getMoreResults(); 46 | statement.setFetchDirection(FETCH_REVERSE); 47 | assertThat(statement.getFetchDirection()).isEqualTo(FETCH_REVERSE); 48 | statement.setFetchSize(1234); 49 | assertThat(statement.getFetchSize()).isEqualTo(1234); 50 | statement.getResultSetConcurrency(); 51 | statement.getResultSetType(); 52 | statement.addBatch("SELECT 1;"); 53 | statement.executeBatch(); 54 | statement.clearBatch(); 55 | statement.getConnection(); 56 | statement.getMoreResults(Statement.KEEP_CURRENT_RESULT); 57 | connection.createStatement( 58 | TYPE_SCROLL_SENSITIVE, 59 | CONCUR_UPDATABLE 60 | ).getGeneratedKeys(); 61 | connection.createStatement( 62 | TYPE_SCROLL_SENSITIVE, 63 | CONCUR_UPDATABLE 64 | ).executeUpdate( 65 | "CREATE SEQUENCE mysequence2;", 66 | Statement.NO_GENERATED_KEYS 67 | ); 68 | connection.createStatement( 69 | TYPE_SCROLL_SENSITIVE, 70 | CONCUR_UPDATABLE 71 | ).executeUpdate( 72 | "CREATE SEQUENCE mysequence3;", 73 | new int[]{} 74 | ); 75 | connection.createStatement( 76 | TYPE_SCROLL_SENSITIVE, 77 | CONCUR_UPDATABLE 78 | ).executeUpdate( 79 | "CREATE SEQUENCE mysequence4;", 80 | new String[]{} 81 | ); 82 | connection.createStatement( 83 | TYPE_SCROLL_SENSITIVE, 84 | CONCUR_UPDATABLE 85 | ).execute( 86 | "CREATE SEQUENCE mysequence5;", 87 | RETURN_GENERATED_KEYS 88 | ); 89 | connection.createStatement( 90 | TYPE_SCROLL_SENSITIVE, 91 | CONCUR_UPDATABLE 92 | ).execute( 93 | "CREATE SEQUENCE mysequence6;", 94 | new int[]{} 95 | ); 96 | connection.createStatement( 97 | TYPE_SCROLL_SENSITIVE, 98 | CONCUR_UPDATABLE 99 | ).execute( 100 | "CREATE SEQUENCE mysequence7;", 101 | new String[]{} 102 | ); 103 | statement.getResultSetHoldability(); 104 | statement.isClosed(); 105 | statement.closeOnCompletion(); 106 | statement.isCloseOnCompletion(); 107 | statement.setLargeMaxRows(12345); 108 | assertThatThrownBy(statement::getLargeMaxRows).isInstanceOf(SQLFeatureNotSupportedException.class); 109 | assertThatThrownBy(statement::executeLargeBatch).isInstanceOf(SQLFeatureNotSupportedException.class); 110 | assertThatThrownBy(() -> statement.executeLargeUpdate("CREATE SEQUENCE mysequence2;")) 111 | .isInstanceOf(SQLFeatureNotSupportedException.class); 112 | assertThatThrownBy(() -> statement.executeLargeUpdate("CREATE SEQUENCE mysequence2;", new int[]{})) 113 | .isInstanceOf(SQLFeatureNotSupportedException.class); 114 | assertThatThrownBy(() -> statement.executeLargeUpdate("CREATE SEQUENCE mysequence2;", new String[]{})) 115 | .isInstanceOf(SQLFeatureNotSupportedException.class); 116 | assertThatThrownBy(statement::cancel).isInstanceOf(SQLFeatureNotSupportedException.class); 117 | statement.close(); 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/consistency/WaitingReplicaConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.consistency; 2 | 3 | import com.atlassian.db.replica.spi.ReplicaConsistency; 4 | 5 | import java.sql.Connection; 6 | import java.util.function.Supplier; 7 | 8 | public class WaitingReplicaConsistency implements ReplicaConsistency { 9 | private final ReplicaConsistency consistency; 10 | 11 | public WaitingReplicaConsistency(ReplicaConsistency consistency) { 12 | this.consistency = consistency; 13 | } 14 | 15 | @Override 16 | public void write(Connection main) { 17 | consistency.write(main); 18 | } 19 | 20 | @Override 21 | public boolean isConsistent(Supplier replica) { 22 | for (int i = 0; i < 30; i++) { 23 | if (consistency.isConsistent(replica)) { 24 | return true; 25 | } 26 | try { 27 | Thread.sleep(1000); 28 | } catch (InterruptedException e) { 29 | throw new RuntimeException(e); 30 | } 31 | } 32 | throw new RuntimeException("Replica is still inconsistent after 30s."); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/AuroraClusterTest.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora; 2 | 3 | import com.atlassian.db.replica.api.DualConnection; 4 | import com.atlassian.db.replica.api.SqlCall; 5 | import com.atlassian.db.replica.api.reason.Reason; 6 | import com.atlassian.db.replica.api.reason.RouteDecision; 7 | import com.atlassian.db.replica.it.example.aurora.app.User; 8 | import com.atlassian.db.replica.it.example.aurora.app.Users; 9 | import com.atlassian.db.replica.it.example.aurora.replica.AuroraConnectionProvider; 10 | import com.atlassian.db.replica.it.example.aurora.replica.ConsistencyFactory; 11 | import com.atlassian.db.replica.it.example.aurora.utils.DecisionLog; 12 | import com.atlassian.db.replica.it.example.aurora.utils.ReplicationLag; 13 | import com.atlassian.db.replica.spi.ReplicaConnectionPerUrlProvider; 14 | import com.atlassian.db.replica.spi.ConnectionProvider; 15 | import com.atlassian.db.replica.spi.DatabaseCall; 16 | import com.atlassian.db.replica.internal.DefaultReplicaConnectionPerUrlProvider; 17 | import com.atlassian.db.replica.spi.ReplicaConsistency; 18 | import org.junit.jupiter.api.Disabled; 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.sql.Connection; 22 | import java.sql.SQLException; 23 | import java.util.Collection; 24 | import java.util.List; 25 | import java.util.stream.Collectors; 26 | 27 | import static com.atlassian.db.replica.api.reason.Reason.READ_OPERATION; 28 | import static com.atlassian.db.replica.api.reason.Reason.REPLICA_INCONSISTENT; 29 | import static org.assertj.core.api.Assertions.assertThat; 30 | 31 | class AuroraClusterTest { 32 | final String databaseName = "newdb"; 33 | final String readerEndpoint = "database-1.cluster-ro-crmnlihjxqlm.eu-central-1.rds.amazonaws.com:5432"; 34 | final String readerJdbcUrl = "jdbc:postgresql://" + readerEndpoint + "/" + databaseName; 35 | final String writerJdbcUrl = "jdbc:postgresql://database-1.cluster-crmnlihjxqlm.eu-central-1.rds.amazonaws.com:5432" + "/" + databaseName; 36 | final String jdbcUsername = "postgres"; 37 | final String jdbcPassword = System.getenv("password"); 38 | 39 | @Test 40 | @Disabled 41 | void shouldUtilizeReplicaForReadQueriesForSynchronisedWrites() throws SQLException { 42 | final DecisionLog decisionLog = new DecisionLog(); 43 | final SqlCall connectionPool = initializeConnectionPool(decisionLog); 44 | new ReplicationLag(connectionPool).set(10); 45 | final Users users = new Users(connectionPool); 46 | final User newUser = new User(); 47 | 48 | users.add(newUser); 49 | final Collection allUsers = users.fetch(); 50 | 51 | final List reasons = decisionLog.getDecisions().stream().map(RouteDecision::getReason).collect( 52 | Collectors.toList()); 53 | assertThat(allUsers).contains(newUser); 54 | assertThat(decisionLog.getDecisions()).contains(new RouteDecision( 55 | "SELECT username FROM users", 56 | READ_OPERATION, 57 | null 58 | )); 59 | 60 | assertThat(reasons).isNotEmpty().doesNotContain(REPLICA_INCONSISTENT); 61 | } 62 | 63 | private SqlCall initializeConnectionPool(final DatabaseCall decisionLog) throws SQLException { 64 | final ConnectionProvider connectionProvider = new AuroraConnectionProvider( 65 | readerJdbcUrl, 66 | writerJdbcUrl 67 | ); 68 | 69 | ReplicaConnectionPerUrlProvider replicaConnectionPerUrlProvider = new DefaultReplicaConnectionPerUrlProvider( 70 | jdbcUsername, 71 | jdbcPassword 72 | ); 73 | 74 | final ReplicaConsistency replicaConsistency = new ConsistencyFactory( 75 | connectionProvider, 76 | replicaConnectionPerUrlProvider 77 | ).create(); 78 | 79 | return () -> DualConnection.builder(connectionProvider, replicaConsistency) 80 | .databaseCall(decisionLog) 81 | .build(); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/app/User.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.app; 2 | 3 | import java.util.Objects; 4 | import java.util.UUID; 5 | 6 | public class User { 7 | private final String name; 8 | 9 | public User() { 10 | this.name = UUID.randomUUID().toString(); 11 | } 12 | 13 | public User(String name) { 14 | this.name = name; 15 | } 16 | 17 | public String getName() { 18 | return name; 19 | } 20 | 21 | @Override 22 | public boolean equals(Object o) { 23 | if (this == o) return true; 24 | if (o == null || getClass() != o.getClass()) return false; 25 | User user = (User) o; 26 | return Objects.equals(name, user.name); 27 | } 28 | 29 | @Override 30 | public int hashCode() { 31 | return Objects.hash(name); 32 | } 33 | 34 | @Override 35 | public String toString() { 36 | return "User{" + 37 | "name='" + name + '\'' + 38 | '}'; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/app/Users.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.app; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | import com.google.common.collect.ImmutableList; 5 | 6 | import java.sql.Connection; 7 | import java.sql.PreparedStatement; 8 | import java.sql.ResultSet; 9 | import java.sql.SQLException; 10 | import java.util.Collection; 11 | 12 | public class Users { 13 | private final SqlCall connectionSupplier; 14 | 15 | public Users(SqlCall connectionSupplier) throws SQLException { 16 | this.connectionSupplier = connectionSupplier; 17 | initialize(); 18 | } 19 | 20 | public void add(User user) throws SQLException { 21 | try (final Connection dualConnection = connectionSupplier.call()) { 22 | insertNewUser(dualConnection, user.getName()); 23 | } 24 | } 25 | 26 | public Collection fetch() throws SQLException { 27 | try (final Connection connection = connectionSupplier.call()) { 28 | final ImmutableList.Builder usersBuilder = ImmutableList.builder(); 29 | final PreparedStatement preparedStatement = connection.prepareStatement( 30 | "SELECT username FROM users"); 31 | final ResultSet resultSet = preparedStatement.executeQuery(); 32 | while (resultSet.next()) { 33 | final String username = resultSet.getString(1); 34 | usersBuilder.add(new User(username)); 35 | } 36 | return usersBuilder.build(); 37 | } 38 | } 39 | 40 | private void initialize() throws SQLException { 41 | try (final Connection connection = connectionSupplier.call()) { 42 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 43 | "CREATE TABLE IF NOT EXISTS users (username VARCHAR ( 50 ));")) { 44 | preparedStatement.executeUpdate(); 45 | } 46 | } 47 | } 48 | 49 | private void insertNewUser(Connection writerConnection, String newUesrName) throws SQLException { 50 | try (final PreparedStatement preparedStatement = writerConnection.prepareStatement( 51 | "INSERT INTO users VALUES(?)")) { 52 | preparedStatement.setString(1, newUesrName); 53 | preparedStatement.executeUpdate(); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/replica/AuroraConnectionProvider.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.replica; 2 | 3 | import com.atlassian.db.replica.spi.ConnectionProvider; 4 | 5 | import java.sql.Connection; 6 | import java.sql.DriverManager; 7 | import java.sql.SQLException; 8 | 9 | public final class AuroraConnectionProvider implements ConnectionProvider { 10 | private final String readerUrl; 11 | private final String writerUrl; 12 | 13 | public AuroraConnectionProvider(String readerUrl, String writerUrl) { 14 | this.readerUrl = readerUrl; 15 | this.writerUrl = writerUrl; 16 | } 17 | 18 | @Override 19 | public boolean isReplicaAvailable() { 20 | return true; 21 | } 22 | 23 | @Override 24 | public Connection getMainConnection() throws SQLException { 25 | return getConnection(writerUrl); 26 | } 27 | 28 | @Override 29 | public Connection getReplicaConnection() throws SQLException { 30 | return getConnection(readerUrl); 31 | } 32 | 33 | private Connection getConnection(String url) throws SQLException { 34 | return DriverManager.getConnection( 35 | url, 36 | "postgres", 37 | System.getenv("password") 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/replica/ConsistencyFactory.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.replica; 2 | 3 | import com.atlassian.db.replica.api.AuroraMultiReplicaConsistency; 4 | import com.atlassian.db.replica.it.example.aurora.replica.api.SequenceReplicaConsistency; 5 | import com.atlassian.db.replica.it.example.aurora.replica.api.SynchronousWriteConsistency; 6 | import com.atlassian.db.replica.spi.ConnectionProvider; 7 | import com.atlassian.db.replica.spi.ReplicaConnectionPerUrlProvider; 8 | import com.atlassian.db.replica.spi.ReplicaConsistency; 9 | 10 | import java.sql.Connection; 11 | import java.sql.PreparedStatement; 12 | import java.sql.SQLException; 13 | 14 | public class ConsistencyFactory { 15 | private final ConnectionProvider connectionProvider; 16 | private final ReplicaConnectionPerUrlProvider replicaConnectionPerUrlProvider; 17 | 18 | public ConsistencyFactory( 19 | ConnectionProvider connectionProvider, 20 | ReplicaConnectionPerUrlProvider replicaConnectionPerUrlProvider 21 | ) { 22 | this.connectionProvider = connectionProvider; 23 | this.replicaConnectionPerUrlProvider = replicaConnectionPerUrlProvider; 24 | } 25 | 26 | public ReplicaConsistency create() throws SQLException { 27 | initialize(); 28 | final SequenceReplicaConsistency sequenceReplicaConsistency = SequenceReplicaConsistency.builder() 29 | .sequenceName("read_replica_replication") 30 | .build(); 31 | final ReplicaConsistency multiReplicaConsistency = AuroraMultiReplicaConsistency.builder() 32 | .replicaConsistency(sequenceReplicaConsistency) 33 | .replicaConnectionPerUrlProvider(replicaConnectionPerUrlProvider) 34 | .build(); 35 | return new SynchronousWriteConsistency(multiReplicaConsistency, connectionProvider); 36 | } 37 | 38 | private void initialize() throws SQLException { 39 | try (final Connection connection = connectionProvider.getMainConnection()) { 40 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 41 | "CREATE TABLE IF NOT EXISTS read_replica_replication\n" + 42 | " (\n" + 43 | " id integer PRIMARY KEY,\n" + 44 | " lsn bigint NOT NULL\n" + 45 | " );")) { 46 | preparedStatement.executeUpdate(); 47 | } 48 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 49 | " INSERT INTO read_replica_replication (id, lsn)\n" + 50 | " SELECT 1, 1 WHERE 1 NOT IN (SELECT id FROM read_replica_replication);")) { 51 | preparedStatement.executeUpdate(); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/replica/api/AuroraSequence.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.replica.api; 2 | 3 | import java.sql.Connection; 4 | import java.sql.PreparedStatement; 5 | import java.sql.ResultSet; 6 | 7 | import static java.lang.String.format; 8 | 9 | public final class AuroraSequence { 10 | private final String sequenceName; 11 | 12 | public AuroraSequence(String sequenceName) { 13 | this.sequenceName = sequenceName; 14 | } 15 | 16 | public void tryBump(Connection connection) { 17 | try ( 18 | PreparedStatement query = prepareBumpSequenceQuery(connection) 19 | ) { 20 | query.executeUpdate(); 21 | if (!connection.getAutoCommit()) { 22 | connection.commit(); 23 | } 24 | } catch (Exception e) { 25 | throw new RuntimeException(format("Can't bump sequence %s", sequenceName), e); 26 | } 27 | } 28 | 29 | public Long fetch(Connection connection) { 30 | try (PreparedStatement query = prepareFetchSequenceValueQuery(connection)) { 31 | query.setQueryTimeout(1); 32 | try (ResultSet results = query.executeQuery()) { 33 | results.next(); 34 | return results.getLong("lsn"); 35 | } 36 | } catch (Exception e) { 37 | throw new RuntimeException(format("error occurred during sequence[%s] value fetching", sequenceName), e); 38 | } 39 | } 40 | 41 | private PreparedStatement prepareBumpSequenceQuery(Connection connection) throws Exception { 42 | return connection.prepareStatement("UPDATE " + sequenceName + " SET lsn = lsn + 1 WHERE ID = (SELECT id FROM " + sequenceName + " FOR UPDATE SKIP LOCKED);"); 43 | } 44 | 45 | private PreparedStatement prepareFetchSequenceValueQuery(Connection connection) throws Exception { 46 | return connection.prepareStatement("SELECT lsn FROM " + sequenceName + ";"); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/replica/api/SequenceReplicaConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.replica.api; 2 | 3 | import com.atlassian.db.replica.api.ThrottledCache; 4 | import com.atlassian.db.replica.internal.MonotonicMemoryCache; 5 | import com.atlassian.db.replica.internal.aurora.ReplicaNode; 6 | import com.atlassian.db.replica.spi.Cache; 7 | import com.atlassian.db.replica.spi.ReplicaConsistency; 8 | import com.atlassian.db.replica.spi.SuppliedCache; 9 | 10 | import java.sql.Connection; 11 | import java.time.Clock; 12 | import java.time.Duration; 13 | import java.util.concurrent.ConcurrentHashMap; 14 | import java.util.function.Supplier; 15 | 16 | import static java.time.Duration.ofSeconds; 17 | 18 | @SuppressWarnings({"SqlNoDataSourceInspection", "SqlResolve"}) 19 | public class SequenceReplicaConsistency implements ReplicaConsistency { 20 | public static final Duration LSN_CHECK_LOCK_TIMEOUT = ofSeconds(1); 21 | 22 | private final AuroraSequence sequence; 23 | private final Cache lastWrite; 24 | private final boolean unknownWritesFallback; 25 | private final ConcurrentHashMap> multiReplicaLsnCache; 26 | private final ReplicaNode replicaNode; 27 | 28 | SequenceReplicaConsistency( 29 | String sequenceName, 30 | Cache lastWrite, 31 | boolean unknownWritesFallback, 32 | ConcurrentHashMap> multiReplicaLsnCache, 33 | ReplicaNode replicaNode 34 | ) { 35 | this.sequence = new AuroraSequence(sequenceName); 36 | this.lastWrite = lastWrite; 37 | this.unknownWritesFallback = unknownWritesFallback; 38 | this.multiReplicaLsnCache = multiReplicaLsnCache; 39 | this.replicaNode = replicaNode; 40 | } 41 | 42 | public static Builder builder() { 43 | return new Builder(); 44 | } 45 | 46 | @Override 47 | public void write(Connection main) { 48 | try { 49 | long sequenceValue = sequence.fetch(main) + 1; 50 | lastWrite.put(sequenceValue); 51 | sequence.tryBump(main); 52 | } catch (Exception e) { 53 | throw new RuntimeException("Can't update consistency state.", e); 54 | } 55 | } 56 | 57 | @Override 58 | public boolean isConsistent(Supplier replica) { 59 | try { 60 | return lastWrite.get() 61 | .map(lastWrite1 -> computeReplicasConsistency(replica.get(), lastWrite1)) 62 | .orElse(unknownWritesFallback); 63 | } catch (Exception e) { 64 | throw new RuntimeException("Exception occurred during consistency checking.", e); 65 | } 66 | } 67 | 68 | private boolean computeReplicasConsistency(Connection replica, long lastWrite) { 69 | tryRefreshLsnCacheForCurrentReplica(replica); 70 | return isConsistent(replica, lastWrite); 71 | } 72 | 73 | private void tryRefreshLsnCacheForCurrentReplica(Connection replica) { 74 | String replicaId = replicaNode.get(replica); 75 | if (replicaId != null) { 76 | multiReplicaLsnCache.computeIfAbsent( 77 | replicaId, 78 | x -> new ThrottledCache<>(Clock.systemUTC(), LSN_CHECK_LOCK_TIMEOUT) 79 | ) 80 | .get(() -> sequence.fetch(replica)); 81 | } 82 | } 83 | 84 | private boolean isConsistent(Connection replica, long lastWrite) { 85 | final Long lsn = multiReplicaLsnCache.get(replicaNode.get(replica)).get().orElse(0L); 86 | return lsn >= lastWrite; 87 | } 88 | 89 | public static class Builder { 90 | private String sequenceName; 91 | private Cache lastWrite = new MonotonicMemoryCache<>(); 92 | private boolean unknownWritesFallback = false; 93 | private ConcurrentHashMap> multiReplicaLsnCache = new ConcurrentHashMap<>(); 94 | private ReplicaNode replicaNode = new ReplicaNode(); 95 | 96 | public Builder sequenceName(String sequenceName) { 97 | this.sequenceName = sequenceName; 98 | return this; 99 | } 100 | 101 | public Builder lastWrite(Cache lastWrite) { 102 | this.lastWrite = lastWrite; 103 | return this; 104 | } 105 | 106 | public Builder unknownWritesFallback(boolean unknownWritesFallback) { 107 | this.unknownWritesFallback = unknownWritesFallback; 108 | return this; 109 | } 110 | 111 | public Builder multiReplicaLsnCache(ConcurrentHashMap> multiReplicaLsnCache) { 112 | this.multiReplicaLsnCache = multiReplicaLsnCache; 113 | return this; 114 | } 115 | 116 | public Builder replicaNode(ReplicaNode replicaNode) { 117 | this.replicaNode = replicaNode; 118 | return this; 119 | } 120 | 121 | /** 122 | * @deprecated use {@link Builder#sequenceName(String)}{@code .}{@link Builder#build()} instead. 123 | */ 124 | @Deprecated 125 | public SequenceReplicaConsistency build(String sequenceName) { 126 | return sequenceName(sequenceName).build(); 127 | } 128 | 129 | public SequenceReplicaConsistency build() { 130 | return new SequenceReplicaConsistency( 131 | sequenceName, 132 | lastWrite, 133 | unknownWritesFallback, 134 | multiReplicaLsnCache, 135 | replicaNode 136 | ); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/replica/api/SynchronousWriteConsistency.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.replica.api; 2 | 3 | import com.atlassian.db.replica.internal.LazyReference; 4 | import com.atlassian.db.replica.spi.ConnectionProvider; 5 | import com.atlassian.db.replica.spi.ReplicaConsistency; 6 | 7 | import java.sql.Connection; 8 | import java.time.Duration; 9 | import java.time.Instant; 10 | import java.util.function.Supplier; 11 | 12 | import static java.lang.Math.max; 13 | import static java.lang.Math.min; 14 | import static java.lang.String.format; 15 | 16 | /** 17 | * Waits until writes propagate to replica. 18 | * It doesn't depend on a cross-JVM cache. 19 | */ 20 | 21 | public class SynchronousWriteConsistency implements ReplicaConsistency { 22 | private final ReplicaConsistency replicaConsistency; 23 | private final ConnectionProvider connections; 24 | 25 | /** 26 | * @param replicaConsistency checks consistency 27 | * @param connections connects to replica during polling 28 | */ 29 | public SynchronousWriteConsistency( 30 | ReplicaConsistency replicaConsistency, 31 | ConnectionProvider connections 32 | ) { 33 | this.replicaConsistency = replicaConsistency; 34 | this.connections = connections; 35 | } 36 | 37 | @Override 38 | public void write(Connection connection) { 39 | replicaConsistency.write(connection); 40 | Waiting waiting = new Waiting(replicaConsistency, connections); 41 | waiting.waitUntilConsistent(); 42 | } 43 | 44 | @Override 45 | public boolean isConsistent(Supplier supplier) { 46 | return replicaConsistency.isConsistent(supplier); 47 | } 48 | 49 | public static class Waiting { 50 | private final ReplicaConsistency consistency; 51 | private final ConnectionProvider connections; 52 | 53 | public Waiting( 54 | ReplicaConsistency consistency, 55 | ConnectionProvider connections 56 | ) { 57 | this.consistency = consistency; 58 | this.connections = connections; 59 | } 60 | 61 | public void waitUntilConsistent() { 62 | try { 63 | waitUntilConsistent(getTimeout()); 64 | } catch (Exception exception) { 65 | throw new RuntimeException("TODO", exception); 66 | } 67 | } 68 | 69 | private void waitUntilConsistent(Duration timeout) throws Exception { 70 | final Instant end = Instant.now().plus(Duration.ofMillis(adjustTimeout(timeout))); 71 | while (Instant.now().isBefore(end)) { 72 | final boolean isConsistent = checkConsistency(); 73 | if (isConsistent) { 74 | return; 75 | } 76 | Thread.sleep(10); 77 | } 78 | throw new RuntimeException(format("Waiting for consistency failed: %s", timeout)); 79 | } 80 | 81 | private long adjustTimeout(Duration timeout) { 82 | final Duration maxTimeout = getTimeout(); 83 | return max(min(timeout.toMillis(), maxTimeout.toMillis()), 10); 84 | } 85 | 86 | private boolean checkConsistency() { 87 | try (ConnectionSupplier replica = new ConnectionSupplier(connections)) { 88 | return consistency.isConsistent(replica); 89 | } catch (Exception e) { 90 | throw new RuntimeException(format("Checking consistency failed: %s", consistency), e); 91 | } 92 | } 93 | 94 | private Duration getTimeout() { 95 | return Duration.ofSeconds(30); 96 | } 97 | 98 | } 99 | 100 | private static class ConnectionSupplier implements Supplier, AutoCloseable { 101 | private final LazyReference connection; 102 | 103 | private ConnectionSupplier(ConnectionProvider connectionProvider) { 104 | connection = new LazyReference() { 105 | @Override 106 | protected Connection create() throws Exception { 107 | return connectionProvider.getReplicaConnection(); 108 | } 109 | }; 110 | } 111 | 112 | @Override 113 | public void close() throws Exception { 114 | if (connection.isInitialized()) { 115 | final Connection connection = this.connection.get(); 116 | if (connection != null) { 117 | connection.close(); 118 | } 119 | } 120 | } 121 | 122 | @Override 123 | public Connection get() { 124 | return connection.get(); 125 | } 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/utils/DecisionLog.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.utils; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | import com.atlassian.db.replica.api.reason.RouteDecision; 5 | import com.atlassian.db.replica.spi.DatabaseCall; 6 | import com.google.common.collect.ImmutableList; 7 | 8 | import java.sql.SQLException; 9 | import java.util.List; 10 | 11 | public final class DecisionLog implements DatabaseCall { 12 | private final ImmutableList.Builder decisions = new ImmutableList.Builder<>(); 13 | 14 | @Override 15 | public T call(SqlCall call, RouteDecision decision) throws SQLException { 16 | decisions.add(decision); 17 | return call.call(); 18 | } 19 | 20 | public List getDecisions() { 21 | return decisions.build(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/com/atlassian/db/replica/it/example/aurora/utils/ReplicationLag.java: -------------------------------------------------------------------------------- 1 | package com.atlassian.db.replica.it.example.aurora.utils; 2 | 3 | import com.atlassian.db.replica.api.SqlCall; 4 | 5 | import java.sql.Connection; 6 | import java.sql.PreparedStatement; 7 | import java.sql.SQLException; 8 | 9 | public final class ReplicationLag { 10 | private final SqlCall connectionSupplier; 11 | 12 | public ReplicationLag(SqlCall connectionSupplier) { 13 | this.connectionSupplier = connectionSupplier; 14 | } 15 | 16 | public void set(int seconds) throws SQLException { 17 | try (final Connection connection = connectionSupplier.call()) { 18 | try (final PreparedStatement preparedStatement = connection.prepareStatement( 19 | "SELECT aurora_inject_replica_failure(100, " + seconds + ", '');")) { 20 | preparedStatement.executeQuery(); 21 | } 22 | } 23 | } 24 | } 25 | --------------------------------------------------------------------------------