diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33bdd50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Java 21 validation + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Compile, test, and generate coverage report + run: mvn -B -ntp verify + + benchmark-smoke: + name: JMH smoke test + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Build benchmark executable + run: mvn -B -ntp -Pbenchmarks -DskipTests package + - name: Run short benchmark + run: >- + java -jar target/benchmarks.jar BoundedBlockingQueueBenchmark + -wi 1 -i 1 -f 1 -w 100ms -r 100ms diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94c5f2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +target/ +.idea/ +*.iml +.classpath +.project +.settings/ +.DS_Store +hs_err_pid* diff --git a/LICENSE b/LICENSE index 261eeb9..0d89c4a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,21 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +MIT License + +Copyright (c) 2026 Avishay Zamir + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9bd3b23..8b70c8b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,243 @@ -# my-private +# Java Backend Primitives -Accumulator -SharedQueue -Queue +[![CI](https://github.com/azamir911/java-backend-primitives/actions/workflows/ci.yml/badge.svg)](https://github.com/azamir911/java-backend-primitives/actions/workflows/ci.yml) + +A dependency-free Java 21 library of small backend building blocks for concurrency, streaming statistics, precise decimal math, and adaptive batch sizing. + +The project favors explicit contracts, deterministic behavior, and testable lifecycle rules over framework-specific integrations. + +## Status + +This project is currently a pre-release library. The source code is public and licensed under MIT, but the artifact is not yet published to Maven Central or another public package registry. + +To use it today, clone the repository and install the artifact into your local Maven repository. The current coordinates are: + +```text +dev.azamir:java-backend-primitives:1.0.0-SNAPSHOT +``` + +## Requirements + +- Java 21 or later +- Maven 3.9 or later + +## Install locally + +```bash +git clone https://github.com/azamir911/java-backend-primitives.git +cd java-backend-primitives +mvn clean install +``` + +This installs the library into your local Maven repository, normally under `~/.m2/repository`. + +### Maven + +After running `mvn clean install`, add: + +```xml + + dev.azamir + java-backend-primitives + 1.0.0-SNAPSHOT + +``` + +### Gradle + +Use the local Maven repository: + +```kotlin +repositories { + mavenLocal() +} + +dependencies { + implementation("dev.azamir:java-backend-primitives:1.0.0-SNAPSHOT") +} +``` + +## Components + +| Area | Main types | Purpose | +|---|---|---| +| Concurrent queues | `CloseableBlockingQueue`, `BoundedBlockingQueue`, `PollingBackoffQueue` | Bounded FIFO coordination with explicit shutdown behavior | +| Streaming statistics | `StreamingStatistics`, `StatisticsSnapshot` | One-pass descriptive statistics with thread-safe snapshots | +| Decimal ratios | `DecimalRatioCalculator` | Ratio calculations with explicit scale and rounding | +| Adaptive batching | `AdaptiveBatchSizer`, `RetryingSizeProbe` | Estimate safe batch sizes from serialized samples | +| Atomic accumulation | `ResettableLongAccumulator` | Lock-free accumulation with atomic snapshot and reset | + +## Quick start + +### Bounded producer-consumer queue + +```java +import dev.azamir.primitives.concurrent.CloseableBlockingQueue; +import dev.azamir.primitives.concurrent.CloseableQueues; +import dev.azamir.primitives.concurrent.QueueClosedException; +import dev.azamir.primitives.concurrent.QueueWaitStrategy; + +CloseableBlockingQueue queue = + CloseableQueues.bounded(128, QueueWaitStrategy.CONDITION_SIGNALING); + +Thread producer = Thread.ofVirtual().start(() -> { + try { + queue.put("event-1"); + queue.put("event-2"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + queue.close(); + } +}); + +try { + while (true) { + System.out.println(queue.take()); + } +} catch (QueueClosedException drained) { + // The queue was closed and all accepted elements were consumed. +} + +producer.join(); +``` + +Choose `QueueWaitStrategy.POLLING_BACKOFF` when polling behavior is intentionally required. The default condition-signaling implementation is more efficient for normal producer-consumer workloads. + +### Queue lifecycle + +| State | `put` / `offer` | `take` | `poll` | +|---|---|---|---| +| Open with capacity | Accepts an element | Returns or waits | Returns or times out | +| Open and full | Waits or times out | Returns an element | Returns an element | +| Closed with buffered data | Throws `QueueClosedException` | Drains buffered data | Drains buffered data | +| Closed and drained | Throws `QueueClosedException` | Throws `QueueClosedException` | Returns empty | + +`close()` is idempotent and preserves buffered elements for draining. `closeNow()` closes the queue and discards buffered elements. `clear()` removes buffered elements without closing the queue. + +All blocking methods propagate `InterruptedException` and preserve standard Java interruption semantics. + +### Streaming statistics + +```java +import dev.azamir.primitives.statistics.StatisticsSnapshot; +import dev.azamir.primitives.statistics.StreamingStatistics; + +StreamingStatistics statistics = new StreamingStatistics(); +statistics.add(12.5); +statistics.add(18.0); +statistics.add(14.5); + +StatisticsSnapshot snapshot = statistics.snapshot(); + +long count = snapshot.count(); +double sum = snapshot.sum(); +double mean = snapshot.mean().orElseThrow(); +double deviation = snapshot.sampleStandardDeviation().orElseThrow(); +``` + +`StreamingStatistics` calculates count, sum, mean, sample variance, sample standard deviation, minimum, and maximum in one pass. It uses Welford's algorithm for stable online variance and compensated summation to reduce floating-point error. + +Updates, snapshots, and resets are thread-safe. + +### Decimal ratio calculation + +```java +import dev.azamir.primitives.math.DecimalRatioCalculator; +import java.math.BigDecimal; +import java.math.RoundingMode; + +DecimalRatioCalculator ratio = + new DecimalRatioCalculator(new BigDecimal("0.10")); + +BigDecimal result = ratio.calculate( + new BigDecimal("2"), + new BigDecimal("3"), + 2, + RoundingMode.HALF_UP); +``` + +The constructor argument is an optional additive adjustment applied after division. Use `BigDecimal.ZERO` for a plain ratio. + +The calculator validates zero denominators and invalid scales rather than relying on implicit arithmetic failures. + +### Adaptive batch sizing + +```java +import dev.azamir.primitives.batching.AdaptiveBatchSizer; +import dev.azamir.primitives.batching.RetryingSizeProbe; +import java.nio.charset.StandardCharsets; + +byte[] serializedSample = + "record-1,record-2".getBytes(StandardCharsets.UTF_8); +int sampledRecords = 2; +int headerBytes = 4; + +RetryingSizeProbe probe = + new RetryingSizeProbe<>(bytes -> bytes.length, 3); + +AdaptiveBatchSizer sizer = + new AdaptiveBatchSizer( + 1_048_576, // target serialized capacity + 0.85, // safety factor + 1, // minimum batch size + 100_000); // maximum batch size + +int recordsPerBatch = sizer.estimate( + sampledRecords, + serializedSample, + probe, + headerBytes); +``` + +The estimator separates fixed overhead from per-record payload, applies a safety factor, and clamps the result to configured minimum and maximum batch sizes. + +`RetryingSizeProbe` allows transient measurement or serialization failures to be retried within a fixed attempt budget while keeping the sizing algorithm independent from any serializer or framework. + +### Resettable atomic accumulator + +```java +import dev.azamir.primitives.concurrent.ResettableLongAccumulator; + +ResettableLongAccumulator bytes = ResettableLongAccumulator.sum(); +bytes.accumulate(1_024); +bytes.accumulate(2_048); + +long intervalTotal = bytes.getAndReset(); // 3072 +``` + +Custom associative operations are also supported. The supplied operation must be side-effect free because it may be retried during concurrent updates. + +## Build and test + +```bash +mvn verify +``` + +The verification phase: + +- compiles the library with Java 21 +- runs unit tests +- runs multi-threaded stress tests +- creates a JaCoCo report under `target/site/jacoco` + +## Benchmarks + +Build and run the JMH queue benchmark: + +```bash +mvn -Pbenchmarks -DskipTests package +java -jar target/benchmarks.jar +``` + +The benchmark compares condition signaling and polling backoff. Results depend on the machine, JVM, queue capacity, wait strategy, and producer-consumer ratio. The repository includes a reproducible harness rather than publishing a hardware-independent performance claim. + +## Versioning and compatibility + +The current `1.0.0-SNAPSHOT` version is a development build. Public APIs may change before the first tagged stable release. + +A stable release should use a non-SNAPSHOT version and be published to a public artifact registry before consumers rely on it as a remote dependency. + +## License + +MIT diff --git a/calculator/Accumulator.java b/calculator/Accumulator.java deleted file mode 100644 index c89c770..0000000 --- a/calculator/Accumulator.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.calculator; - -import lombok.AccessLevel; -import lombok.experimental.FieldDefaults; - -/** - * Created by Avishay Zamir on 02/01/2020 - * - * A mutable data type that calculates: 1. mean 2. sample variance 3. sample standard deviation 4. maximum of a stream - * of real numbers a stable, one-pass algorithm. - * - * Help can be found here: https://www.calculator.net/standard-deviation-calculator.html - */ -@FieldDefaults(level = AccessLevel.PRIVATE) -public class Accumulator { - - // number of data values - int n; - - // sample variance * (n-1) - double squareSum; - - // sample mean (Average) - double mean; - - // max value - double max; - - // min value - double min; - - // sum of all values - double sum; - - public Accumulator() { - setValues(); - } - - public void reset() { - setValues(); - } - - private void setValues() { - this.n = 0; - this.squareSum = 0.0; - this.mean = 0.0; - this.max = Double.NEGATIVE_INFINITY; - this.min = Double.POSITIVE_INFINITY; - this.sum = 0.0; - } - - /** - * Adds the specified value to the accumulator. - * - * @param value the value - */ - public void add(double value) { - synchronized (this) { - this.n++; - double delta = value - this.mean; - this.mean += delta / this.n; - this.squareSum += (double) (this.n - 1) / this.n * delta * delta; - this.max = Math.max(max, value); - this.min = Math.min(min, value); - this.sum += value; - } - } - - /** - * Returns the mean of the data values (Average). - * - * @return the mean of the data values - */ - public double mean() { - return this.mean; - } - - /** - * Returns the sample variance of the data values. - * - * @return the sample variance of the data values - */ - public double variance() { - if (this.n <= 1) { - return Double.NaN; - } - return this.squareSum / (this.n - 1); - } - - /** - * Returns the sample standard deviation of the data values (StdDev). - * - * @return the sample standard deviation of the data values - */ - public double standardDeviation() { - return Math.sqrt(this.variance()); - } - - /** - * Returns the maximum value of the data values - * - * @return the maximum value of the data values - */ - public double max() { - return this.max; - } - - /** - * Returns the minimum value of the data values - * - * @return the minimum value of the data values - */ - public double min() { - return this.min; - } - - /** - * Returns the number of data values. - * - * @return the number of data values - */ - public int count() { - return this.n; - } - - /** - * Return the summarize of data values - * - * @return the summarize of data values - */ - public double sum() { - return this.sum; - } - - /** - * Returns a string representation of this accumulator. - * - * @return a string representation of this accumulator - */ - public String toString() { - return "Count = " + count() + ", Mean = " + mean() + ", Sample Standard Deviation = " + standardDeviation() - + ", Sample Variance = " + variance() + ", Max = " + max() + ", Min = " + min() + ", Sum = " + sum(); - } - -} diff --git a/calculator/ChunkSizeCalculator.java b/calculator/ChunkSizeCalculator.java deleted file mode 100644 index 9a92b33..0000000 --- a/calculator/ChunkSizeCalculator.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.sisense.build.connector.calculator; - -import com.sisense.build.connector.contracts.ColumnData; -import com.sisense.build.connector.monitor.MonitoringParameters; -import com.sisense.build.connector.pool.chunk.Chunk; -import com.sisense.build.connector.property.DoublePropertyKeys; -import com.sisense.build.connector.property.IntegerPropertyKeys; -import com.sisense.build.connector.bytes.serialization.Serializer; -import com.sisense.build.connector.bytes.serialization.SerializerException; -import com.sisense.common.propertymanager.PropertyManager; -import lombok.AccessLevel; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import javax.inject.Inject; -import java.math.RoundingMode; -import java.nio.ByteBuffer; -import java.util.List; - -/** - * The ChunkSizeCalculator class will calculate the chunk's size in bytes. It will now to calculate the header section - * and the rows section, based on that it will be able to assuming better how many rows should be in each chunk. The - * bufferSize is the maximum size for a chunk in bytes. The factor size is the parameter to evaluate how many records - * will be in the chunk based on the last calculation - */ -@Slf4j -@Component -@FieldDefaults(level = AccessLevel.PRIVATE) -public class ChunkSizeCalculator { - - // The declared buffer size - final int bufferSize; - // the declared factor - final double factor; - // the declared retries - final int sumRetries; - - final RatioCalculator ratioCalculator = new RatioCalculator(0); - - @Inject - private Serializer serializer; - - public ChunkSizeCalculator(PropertyManager propertyManager) { - // Getting the declared buffer size from the ZK - this.bufferSize = propertyManager.getIntPropertyValue(IntegerPropertyKeys.DEFAULT_CHUNK_SIZE.getKeys()); - // The value to multiple with the end result - this.factor = propertyManager.getDoublePropertyValue(DoublePropertyKeys.CHUNK_SIZE_FACTOR.getKeys()); - // The number of retries to do serialize for a Chunk - this.sumRetries = propertyManager.getIntPropertyValue(IntegerPropertyKeys.MAX_SERIALIZE_RETRIES.getKeys()); - } - - /** - * - * @param chunk the chunk on which the calculation will be based - * @param columns the columns in the header of the chunk - * @return the number of rows that should be in each chunk - */ - public int calculate(Chunk chunk, List columns, MonitoringParameters monitoringParameters) - throws Exception { - // If the chunk is empty so no rows to based on the calculation. Return 0. - if (chunk.size() == 0) { - log.debug("BE#293606 Tried to calculated empty Chunk, no data to read"); - return 0; - } - - // Size of buffer without rows, only headers and finish bytes - - // Calculate the columns size in bytes - int columnsSizeInBytes = sizeOf(null, columns, monitoringParameters); - log.debug("BE#135485 The columns' size is '{}' bytes", columnsSizeInBytes); - // If the columns size are bigger than the buffer size so returning the current chunk size. - // That means the size of each chunk will the the value of the NUMBER_OF_RECORDS_FOR_REVALUE_CHUNK_SIZE property - if (columnsSizeInBytes > bufferSize) { - log.warn("BE#733499 Headers size is bigger then default buffer size. Setting number of rows in Chunk " + - "as number of rows in sample Chunk"); - return chunk.size(); - } - - // Calculate the chunk size and columns size in bytes together - int totalSizeInBytes = sizeOf(chunk, columns, monitoringParameters); - log.debug("BE#974139 The total size is: '{}' bytes", totalSizeInBytes); - // Size of byte buffer with out headers and finish bytes - - // Calculate the chunk size in bytes - int rowsSizeInBytes = totalSizeInBytes - columnsSizeInBytes; - log.debug("BE#395536 The rows' size is: '{}' bytes", rowsSizeInBytes); - - // If the chunk is empty so no rows to based on the calculation. Return 0. - if (rowsSizeInBytes == 0) { - log.debug("BE#424455 The Chunk's size in bytes is 0. Setting the number of rows in Chunk as number of " + - "rows in sample Chunk"); - return chunk.size(); - } - - // Calculate the number of rows that fit in a buffer with ChunkSize - // Calculating the number of rows in a chunk - // 1. Reduce the columns size from the buffer size - // 2. Calculating the ratio between #1 and rowsSizeInBytes - // 3. Calculating how many rows on the chunk based on the ratio and the chunk's records size - // 4. Multiple the result with the factor for a final result - final int numerator = bufferSize - columnsSizeInBytes; - final double ratio = ratioCalculator.calculate(numerator, rowsSizeInBytes, 10, RoundingMode.UP); - final double evaluatedRows = ratio * chunk.size(); - final int calculatedRows = (int) (evaluatedRows * factor); - if (calculatedRows == 0) { - log.debug("BE#587383 Calculated rows in chunk is 0. Setting number of rows in chunk as number of " + - "rows in sample chunk"); - return chunk.size(); - } - - return calculatedRows; - } - - /** - * Calculating the chunk size and the columns size after a serialization - * - * @param chunk the chunk to calculate - * @param columns the columns to calculate - * @return the size in bytes - */ - private int sizeOf(Chunk chunk, List columns, MonitoringParameters monitoringParameters) - throws Exception { - int retries = 1; - - // Doing a serialization with only the columns - ByteBuffer serializedBuffer = null; - - while (serializedBuffer == null && retries <= sumRetries) { - try { - retries++; - serializedBuffer = serializer.serialize(chunk, columns, monitoringParameters); - } catch (SerializerException e) { - log.warn("BE#782473 Got exception from Serializer. Retrying {}/{}", retries, sumRetries); - } - } - - if (serializedBuffer == null) { - throw new Exception("BE#472360 Could not estimate chunk size"); - } - - // The position is the size of the serialized columns - int sizeOfChunkInBytes = serializedBuffer.position(); - serializer.put(serializedBuffer); - return sizeOfChunkInBytes; - } -} diff --git a/calculator/RatioCalculator.java b/calculator/RatioCalculator.java deleted file mode 100644 index 4bdd250..0000000 --- a/calculator/RatioCalculator.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.calculator; - -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.math.BigDecimal; -import java.math.RoundingMode; - -/** - * The ratio calculator that gets 2 double numbers and return the ratio based on the scale and rounding mode - */ -@Data -@AllArgsConstructor -public class RatioCalculator { - - double factor; - - /** - * Return the ratio based on the numerator and denominator. Scale is 1 and rounding mode is up - * - * @param numerator the numerator - * @param denominator the denominator - * @return the ratio - */ - public double calculate(double numerator, double denominator) { - return calculate(numerator, denominator, 1, RoundingMode.UP); - } - - /** - * Return the ratio based on the numerator, denominator and scale. Rounding mode is up - * - * @param numerator the numerator - * @param denominator the denominator - * @param scale the scale - * @return the ratio - */ - public double calculate(double numerator, double denominator, int scale) { - return calculate(numerator, denominator, scale, RoundingMode.UP); - } - - /** - * Return the ratio based on the numerator, denominator and rounding mode. Scale is 1 - * - * @param numerator the numerator - * @param denominator the denominator - * @param roundingMode the rounding mode - * @return the ratio - */ - public double calculate(double numerator, double denominator, RoundingMode roundingMode) { - return calculate(numerator, denominator, 1, roundingMode); - } - - /** - * Return the ratio based on the numerator, denominator, rounding mode and scale. - * - * @param numerator the numerator - * @param denominator the denominator - * @param scale the scale - * @param roundingMode the rounding mode - * @return the ratio - */ - public double calculate(double numerator, double denominator, int scale, RoundingMode roundingMode) { - BigDecimal theNumerator = new BigDecimal(numerator); - BigDecimal theDenominator = new BigDecimal(denominator); - final BigDecimal divide = theNumerator.divide(theDenominator, scale, roundingMode); - return divide.add(new BigDecimal(factor)).doubleValue(); - } - -} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2b15b3d --- /dev/null +++ b/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + dev.azamir + java-backend-primitives + 1.0.0-SNAPSHOT + Java Backend Primitives + Dependency-free Java primitives for concurrency, streaming statistics, decimal math, and adaptive batching. + https://github.com/azamir911/java-backend-primitives + + + + MIT License + https://opensource.org/license/mit + repo + + + + + 21 + UTF-8 + 5.11.4 + 1.37 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 + + + enforce-build-environment + + enforce + + + + + [21,) + + + [3.9,) + + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + verify + + report + + + + + + + + + + benchmarks + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-jmh-sources + generate-sources + + add-source + + + + src/jmh/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + benchmarks + false + + + + org.openjdk.jmh.Main + + + + + + + + + + + diff --git a/shared/SharedFiFoLockQueue.java b/shared/SharedFiFoLockQueue.java deleted file mode 100644 index 0b855a0..0000000 --- a/shared/SharedFiFoLockQueue.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.collection.shared; - -import lombok.AccessLevel; -import lombok.NonNull; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; - -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.ReentrantLock; - -@Slf4j -@FieldDefaults(level = AccessLevel.PRIVATE) -public class SharedFiFoLockQueue extends SharedFiFoQueue { - - /** Main lock guarding all access */ - final ReentrantLock lock = new ReentrantLock(); - - /** Condition for waiting takes */ - final Condition notEmpty = lock.newCondition(); - - /** Condition for waiting puts */ - final Condition notFull = lock.newCondition(); - - public SharedFiFoLockQueue(int capacity) { - super(capacity); - } - - @Override - public boolean add(@NonNull T elem) { - final ReentrantLock lock = this.lock; - try { - lock.lockInterruptibly(); - - doAdd(elem); - - notEmpty.signal(); - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#812296 " + e.getMessage()); - } - finally { - lock.unlock(); - } - - return true; - } - - @Override - protected void doAddWait() { - log.debug("BE#040523 Trying to add Element to the Queue while it full. Waiting for signal"); - try { - notFull.await(); - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#010208 " + e.getMessage()); - } - } - - public T remove() { - final ReentrantLock lock = this.lock; - - try { - lock.lockInterruptibly(); - - final T element = doRemove(); - - notFull.signal(); - - return element; - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#555292 " + e.getMessage()); - } - finally { - lock.unlock(); - } - } - - @Override - protected void doRemoveWait() { - log.debug("BE#298013 Trying to get Element from the Queue while it empty. Waiting for signal"); - try { - notEmpty.await(); - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#562572 " + e.getMessage()); - } - } - - @Override - protected void doClear() { - fullSignal(); - } - - private void fullSignal() { - lock.lock(); - try { - notEmpty.signal(); - notFull.signal(); - } - finally { - lock.unlock(); - } - } - -} diff --git a/shared/SharedFiFoLockQueueInterruptedException.java b/shared/SharedFiFoLockQueueInterruptedException.java deleted file mode 100644 index 7d7859e..0000000 --- a/shared/SharedFiFoLockQueueInterruptedException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.collection.shared; - -public class SharedFiFoLockQueueInterruptedException extends RuntimeException { - - private static final long serialVersionUID = 4552016483312675750L; - - public SharedFiFoLockQueueInterruptedException(String message) { - super(message); - } - -} diff --git a/shared/SharedFiFoLockQueueReleasedException.java b/shared/SharedFiFoLockQueueReleasedException.java deleted file mode 100644 index 62029d5..0000000 --- a/shared/SharedFiFoLockQueueReleasedException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.collection.shared; - -public class SharedFiFoLockQueueReleasedException extends RuntimeException { - - private static final long serialVersionUID = -2628636830370021723L; - - public SharedFiFoLockQueueReleasedException(String message) { - super(message); - } - -} diff --git a/shared/SharedFiFoQueue.java b/shared/SharedFiFoQueue.java deleted file mode 100644 index ba94268..0000000 --- a/shared/SharedFiFoQueue.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.collection.shared; - -import lombok.AccessLevel; -import lombok.experimental.FieldDefaults; -import lombok.extern.slf4j.Slf4j; - -import java.util.AbstractQueue; -import java.util.ConcurrentModificationException; -import java.util.Iterator; -import java.util.NoSuchElementException; -import java.util.stream.IntStream; - -@Slf4j -@FieldDefaults(level = AccessLevel.PRIVATE) -public abstract class SharedFiFoQueue extends AbstractQueue { - - final Object[] elements; - - int size = 0; - int placeIndex = 0; - int removeIndex = 0; - - private boolean isReleased = false; - private boolean releaseLocks = false; - - public SharedFiFoQueue(int capacity) { - this.elements = new Object[capacity]; - } - - @Override - public abstract boolean add(T elem); - - @Override - public boolean offer(T t) { - return add(t); - } - - protected final void doAdd(T elem) { - while (size >= elements.length && !isReleased) { - doAddWait(); - } - - inc(); - - if (isReleased) { - throw new SharedFiFoLockQueueReleasedException("BE#700157 Released by the user"); - } - - elements[placeIndex] = elem; - - // We need the modulo, in order to avoid going out of bounds. - placeIndex = (placeIndex + 1) % elements.length; - } - - protected abstract void doAddWait(); - - @Override - public abstract T remove(); - - @Override - public T poll() { - T element = peek(); - - if (element != null) { - dec(); - - // We need the modulo, in order to avoid going out of bounds. - removeIndex = (removeIndex + 1) % elements.length; - } - - return element; - } - - @SuppressWarnings("unchecked") - @Override - public T peek() { - if (releaseLocks && isEmpty()) { - return null; - } - - if (isReleased) { - throw new SharedFiFoLockQueueReleasedException("BE#547121 Released by the user"); - } - - return (T) elements[removeIndex]; - } - - protected final T doRemove() { - while (size <= 0 && !isReleased && !releaseLocks) { - doRemoveWait(); - } - - return poll(); - } - - protected abstract void doRemoveWait(); - - private void inc() { - addToSize(1); - } - - private void dec() { - addToSize(-1); - } - - private synchronized void addToSize(int value) { - size = size + value; - } - - @Override - public Iterator iterator() { - return new Itr(); - } - - @Override - public int size() { - return this.size; - } - - @Override - public boolean isEmpty() { - return this.size == 0; - } - - @Override - public void clear() { - this.isReleased = true; - doClear(); - IntStream.range(0, elements.length).forEach(value -> elements[value] = null); - } - - protected abstract void doClear(); - - public void releaseLocks() { - this.releaseLocks = true; - doClear(); - } - - /** - * Returns {@code true} if this list contains the specified element. More formally, returns {@code true} if and only - * if this list contains at least one element {@code e} such that {@code Objects.equals(o, e)}. - * - * @param o element whose presence in this list is to be tested - * @return {@code true} if this list contains the specified element - */ - @Override - public boolean contains(Object o) { - return indexOf(o) >= 0; - } - - /** - * Returns the index of the first occurrence of the specified element in this list, or -1 if this list does not - * contain the element. More formally, returns the lowest index {@code i} such that - * {@code Objects.equals(o, get(i))}, or -1 if there is no such index. - */ - public int indexOf(Object o) { - return indexOfRange(o, 0, elements.length); - } - - protected int indexOfRange(Object o, int start, int end) { - Object[] es = elements; - if (o == null) { - for (int i = start; i < end; i++) { - if (es[i] == null) { - return i; - } - } - } - else { - for (int i = start; i < end; i++) { - if (o.equals(es[i])) { - return i; - } - } - } - return -1; - } - - /** - * An optimized version of AbstractList.Itr - */ - private class Itr implements Iterator { - - int counter = 0; - int cursor = removeIndex; // index of next element to return - int lastRet = -1; // index of last element returned; -1 if no such - - public boolean hasNext() { - return counter != size(); - } - - @SuppressWarnings("unchecked") - public T next() { - int i = cursor; - if (counter >= size()) { - throw new NoSuchElementException("BE#162250 Element " + i + " does not exists"); - } - if (i >= elements.length) { - throw new ConcurrentModificationException("BE#059680 Trying to get element greater than the size"); - } - - // We need the modulo, in order to avoid going out of bounds. - cursor = (cursor + 1) % elements.length; - counter++; - - return (T) elements[lastRet = i]; - } - } - -} diff --git a/shared/SharedFiFoSleepingQueue.java b/shared/SharedFiFoSleepingQueue.java deleted file mode 100644 index adb0cf8..0000000 --- a/shared/SharedFiFoSleepingQueue.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.collection.shared; - -import lombok.NonNull; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class SharedFiFoSleepingQueue extends SharedFiFoQueue { - - public SharedFiFoSleepingQueue(int capacity) { - super(capacity); - } - - @Override - public boolean add(@NonNull T elem) { - doAdd(elem); - - return true; - } - - @Override - protected void doAddWait() { - log.debug("BE#040524 Trying to add Element to the Queue while it full. Sleeping for 100 milliseconds"); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#417414 " + e.getMessage()); - } - } - - public T remove() { - return doRemove(); - } - - @Override - protected void doRemoveWait() { - log.debug("BE#298014 Trying to get Element from the Queue while it empty. Sleeping for 100 milliseconds"); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - throw new SharedFiFoLockQueueInterruptedException("BE#709989 " + e.getMessage()); - } - - } - - @Override - protected void doClear() { - - } - -} diff --git a/shared/SharedFifoQueueMode.java b/shared/SharedFifoQueueMode.java deleted file mode 100644 index 1284eb7..0000000 --- a/shared/SharedFifoQueueMode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.collection.shared; - -import lombok.AccessLevel; -import lombok.experimental.FieldDefaults; - -@FieldDefaults(level = AccessLevel.PRIVATE) -public enum SharedFifoQueueMode { - LOCK, SLEEPING; - - final String value; - - SharedFifoQueueMode() { - this.value = this.name().toLowerCase(); - } - - public static SharedFifoQueueMode fromValue(String text, SharedFifoQueueMode defaultValue) { - final String s = text.toLowerCase(); - for (SharedFifoQueueMode b : SharedFifoQueueMode.values()) { - if (b.value.equals(s)) { - return b; - } - } - return defaultValue; - } -} diff --git a/src/jmh/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueBenchmark.java b/src/jmh/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueBenchmark.java new file mode 100644 index 0000000..986002c --- /dev/null +++ b/src/jmh/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueBenchmark.java @@ -0,0 +1,63 @@ +package dev.azamir.primitives.concurrent; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Group; +import org.openjdk.jmh.annotations.GroupThreads; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 3, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 500, timeUnit = TimeUnit.MILLISECONDS) +@Fork(1) +public class BoundedBlockingQueueBenchmark { + private static final Duration OPERATION_TIMEOUT = Duration.ofMillis(100); + + @State(Scope.Group) + public static class QueueState { + @Param({"1024"}) + int capacity; + + @Param({"CONDITION_SIGNALING", "POLLING_BACKOFF"}) + QueueWaitStrategy strategy; + + CloseableBlockingQueue queue; + + @Setup(Level.Iteration) + public void setUp() { + queue = CloseableQueues.bounded(capacity, strategy, Duration.ofNanos(1_000)); + } + + @TearDown(Level.Iteration) + public void tearDown() { + queue.closeNow(); + } + } + + @Benchmark + @Group("producerConsumer") + @GroupThreads(1) + public boolean offer(QueueState state) throws InterruptedException { + return state.queue.offer(1, OPERATION_TIMEOUT); + } + + @Benchmark + @Group("producerConsumer") + @GroupThreads(1) + public int poll(QueueState state) throws InterruptedException { + return state.queue.poll(OPERATION_TIMEOUT).orElse(0); + } +} diff --git a/src/main/java/dev/azamir/primitives/batching/AdaptiveBatchSizer.java b/src/main/java/dev/azamir/primitives/batching/AdaptiveBatchSizer.java new file mode 100644 index 0000000..fc52003 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/batching/AdaptiveBatchSizer.java @@ -0,0 +1,99 @@ +package dev.azamir.primitives.batching; + +import java.util.Objects; + +/** + * Estimates a safe batch size from a representative serialized sample. + * + *

The calculation separates fixed overhead from per-record payload, projects how many records fit + * in the target capacity, applies a safety factor, and clamps the result to configured limits. + */ +public final class AdaptiveBatchSizer { + private final long targetCapacityBytes; + private final double safetyFactor; + private final int minimumBatchSize; + private final int maximumBatchSize; + + public AdaptiveBatchSizer(long targetCapacityBytes, double safetyFactor) { + this(targetCapacityBytes, safetyFactor, 1, Integer.MAX_VALUE); + } + + public AdaptiveBatchSizer( + long targetCapacityBytes, + double safetyFactor, + int minimumBatchSize, + int maximumBatchSize) { + if (targetCapacityBytes <= 0) { + throw new IllegalArgumentException("targetCapacityBytes must be greater than zero"); + } + if (!Double.isFinite(safetyFactor) || safetyFactor <= 0 || safetyFactor > 1) { + throw new IllegalArgumentException("safetyFactor must be in the range (0, 1]"); + } + if (minimumBatchSize <= 0) { + throw new IllegalArgumentException("minimumBatchSize must be greater than zero"); + } + if (maximumBatchSize < minimumBatchSize) { + throw new IllegalArgumentException("maximumBatchSize must be at least minimumBatchSize"); + } + this.targetCapacityBytes = targetCapacityBytes; + this.safetyFactor = safetyFactor; + this.minimumBatchSize = minimumBatchSize; + this.maximumBatchSize = maximumBatchSize; + } + + public int estimate( + int sampleRecordCount, long serializedSampleBytes, long fixedOverheadBytes) { + if (sampleRecordCount < 0) { + throw new IllegalArgumentException("sampleRecordCount must not be negative"); + } + if (sampleRecordCount == 0) { + return 0; + } + if (serializedSampleBytes < 0 || fixedOverheadBytes < 0) { + throw new IllegalArgumentException("serialized sizes must not be negative"); + } + if (serializedSampleBytes < fixedOverheadBytes) { + throw new IllegalArgumentException( + "serializedSampleBytes must include and therefore be at least fixedOverheadBytes"); + } + + if (fixedOverheadBytes >= targetCapacityBytes) { + return clamp(sampleRecordCount); + } + + long payloadBytes = serializedSampleBytes - fixedOverheadBytes; + if (payloadBytes == 0) { + return clamp(sampleRecordCount); + } + + double averageRecordBytes = (double) payloadBytes / sampleRecordCount; + double usableCapacity = targetCapacityBytes - fixedOverheadBytes; + long projected = (long) Math.floor((usableCapacity / averageRecordBytes) * safetyFactor); + if (projected <= 0) { + return clamp(sampleRecordCount); + } + return clamp(projected); + } + + public int estimate( + int sampleRecordCount, + T serializedSample, + RetryingSizeProbe totalSizeProbe, + long fixedOverheadBytes) { + Objects.requireNonNull(totalSizeProbe, "totalSizeProbe"); + return estimate( + sampleRecordCount, totalSizeProbe.measureBytes(serializedSample), fixedOverheadBytes); + } + + public long targetCapacityBytes() { + return targetCapacityBytes; + } + + public double safetyFactor() { + return safetyFactor; + } + + private int clamp(long value) { + return (int) Math.max(minimumBatchSize, Math.min(maximumBatchSize, value)); + } +} diff --git a/src/main/java/dev/azamir/primitives/batching/CheckedSizeProbe.java b/src/main/java/dev/azamir/primitives/batching/CheckedSizeProbe.java new file mode 100644 index 0000000..5c326eb --- /dev/null +++ b/src/main/java/dev/azamir/primitives/batching/CheckedSizeProbe.java @@ -0,0 +1,7 @@ +package dev.azamir.primitives.batching; + +/** Measures a serialized size and may fail with a checked exception. */ +@FunctionalInterface +public interface CheckedSizeProbe { + long measureBytes(T value) throws Exception; +} diff --git a/src/main/java/dev/azamir/primitives/batching/RetryingSizeProbe.java b/src/main/java/dev/azamir/primitives/batching/RetryingSizeProbe.java new file mode 100644 index 0000000..523930d --- /dev/null +++ b/src/main/java/dev/azamir/primitives/batching/RetryingSizeProbe.java @@ -0,0 +1,41 @@ +package dev.azamir.primitives.batching; + +import java.util.Objects; + +/** Retries transient serialized-size measurement failures up to a fixed attempt budget. */ +public final class RetryingSizeProbe { + private final CheckedSizeProbe delegate; + private final int maxAttempts; + + public RetryingSizeProbe(CheckedSizeProbe delegate, int maxAttempts) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + if (maxAttempts <= 0) { + throw new IllegalArgumentException("maxAttempts must be greater than zero"); + } + this.maxAttempts = maxAttempts; + } + + public long measureBytes(T value) { + Exception lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + long measured = delegate.measureBytes(value); + if (measured < 0) { + throw new IllegalStateException("measured size must not be negative"); + } + return measured; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new SizeProbeException("size measurement was interrupted", interrupted); + } catch (Exception failure) { + lastFailure = failure; + } + } + throw new SizeProbeException( + "could not measure serialized size after " + maxAttempts + " attempts", lastFailure); + } + + public int maxAttempts() { + return maxAttempts; + } +} diff --git a/src/main/java/dev/azamir/primitives/batching/SizeProbeException.java b/src/main/java/dev/azamir/primitives/batching/SizeProbeException.java new file mode 100644 index 0000000..163f609 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/batching/SizeProbeException.java @@ -0,0 +1,8 @@ +package dev.azamir.primitives.batching; + +/** Raised when a serialized size cannot be measured within the configured retry budget. */ +public final class SizeProbeException extends RuntimeException { + public SizeProbeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/BoundedBlockingQueue.java b/src/main/java/dev/azamir/primitives/concurrent/BoundedBlockingQueue.java new file mode 100644 index 0000000..c9c09f0 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/BoundedBlockingQueue.java @@ -0,0 +1,321 @@ +package dev.azamir.primitives.concurrent; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A bounded FIFO ring buffer with condition-based blocking and an explicit close lifecycle. + * + *

Closing the queue rejects new elements, wakes blocked producers and consumers, and still allows + * consumers to drain elements that were already accepted. Once closed and drained, {@link #take()} + * throws {@link QueueClosedException} while polling methods return an empty result. + * + * @param element type + */ +public final class BoundedBlockingQueue implements CloseableBlockingQueue { + private final Object[] elements; + private final ReentrantLock lock; + private final Condition notEmpty; + private final Condition notFull; + + private int head; + private int tail; + private int size; + private boolean closed; + + public BoundedBlockingQueue(int capacity) { + this(capacity, false); + } + + public BoundedBlockingQueue(int capacity, boolean fair) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be greater than zero"); + } + elements = new Object[capacity]; + lock = new ReentrantLock(fair); + notEmpty = lock.newCondition(); + notFull = lock.newCondition(); + } + + @Override + public void put(E element) throws InterruptedException { + Objects.requireNonNull(element, "element"); + lock.lockInterruptibly(); + try { + while (size == elements.length && !closed) { + notFull.await(); + } + ensureOpenForWrite(); + enqueue(element); + } finally { + lock.unlock(); + } + } + + @Override + public boolean offer(E element, Duration timeout) throws InterruptedException { + Objects.requireNonNull(element, "element"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be negative"); + } + + long remaining = toNanosSaturated(timeout); + lock.lockInterruptibly(); + try { + while (size == elements.length && !closed) { + if (remaining <= 0) { + return false; + } + remaining = notFull.awaitNanos(remaining); + } + ensureOpenForWrite(); + enqueue(element); + return true; + } finally { + lock.unlock(); + } + } + + @Override + public E take() throws InterruptedException { + lock.lockInterruptibly(); + try { + while (size == 0 && !closed) { + notEmpty.await(); + } + if (size == 0) { + throw new QueueClosedException("queue is closed and drained"); + } + return dequeue(); + } finally { + lock.unlock(); + } + } + + @Override + public Optional poll() { + lock.lock(); + try { + return size == 0 ? Optional.empty() : Optional.of(dequeue()); + } finally { + lock.unlock(); + } + } + + @Override + public Optional poll(Duration timeout) throws InterruptedException { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be negative"); + } + + long remaining = toNanosSaturated(timeout); + lock.lockInterruptibly(); + try { + while (size == 0 && !closed) { + if (remaining <= 0) { + return Optional.empty(); + } + remaining = notEmpty.awaitNanos(remaining); + } + return size == 0 ? Optional.empty() : Optional.of(dequeue()); + } finally { + lock.unlock(); + } + } + + @Override + public Optional peek() { + lock.lock(); + try { + return size == 0 ? Optional.empty() : Optional.of(elementAt(0)); + } finally { + lock.unlock(); + } + } + + @Override + public int drainTo(Collection target, int maxElements) { + Objects.requireNonNull(target, "target"); + if (maxElements < 0) { + throw new IllegalArgumentException("maxElements must not be negative"); + } + + lock.lock(); + try { + int drained = Math.min(size, maxElements); + for (int i = 0; i < drained; i++) { + target.add(dequeue()); + } + return drained; + } finally { + lock.unlock(); + } + } + + @Override + public int clear() { + lock.lock(); + try { + int removed = clearElements(); + if (removed > 0) { + notFull.signalAll(); + } + return removed; + } finally { + lock.unlock(); + } + } + + @Override + public int closeNow() { + lock.lock(); + try { + closed = true; + int discarded = clearElements(); + notEmpty.signalAll(); + notFull.signalAll(); + return discarded; + } finally { + lock.unlock(); + } + } + + @Override + public boolean contains(Object value) { + return indexOf(value) >= 0; + } + + @Override + public int indexOf(Object value) { + lock.lock(); + try { + for (int i = 0; i < size; i++) { + if (Objects.equals(value, elements[(head + i) % elements.length])) { + return i; + } + } + return -1; + } finally { + lock.unlock(); + } + } + + @Override + public List snapshot() { + lock.lock(); + try { + List copy = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + copy.add(elementAt(i)); + } + return List.copyOf(copy); + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + notEmpty.signalAll(); + notFull.signalAll(); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isClosed() { + lock.lock(); + try { + return closed; + } finally { + lock.unlock(); + } + } + + @Override + public int size() { + lock.lock(); + try { + return size; + } finally { + lock.unlock(); + } + } + + @Override + public int capacity() { + return elements.length; + } + + @Override + public int remainingCapacity() { + lock.lock(); + try { + return elements.length - size; + } finally { + lock.unlock(); + } + } + + private void ensureOpenForWrite() { + if (closed) { + throw new QueueClosedException("queue is closed for writes"); + } + } + + private void enqueue(E element) { + elements[tail] = element; + tail = (tail + 1) % elements.length; + size++; + notEmpty.signal(); + } + + @SuppressWarnings("unchecked") + private E dequeue() { + E element = (E) elements[head]; + elements[head] = null; + head = (head + 1) % elements.length; + size--; + notFull.signal(); + return element; + } + + private int clearElements() { + int removed = size; + for (int i = 0; i < removed; i++) { + elements[(head + i) % elements.length] = null; + } + head = 0; + tail = 0; + size = 0; + return removed; + } + + @SuppressWarnings("unchecked") + private E elementAt(int logicalIndex) { + return (E) elements[(head + logicalIndex) % elements.length]; + } + + private static long toNanosSaturated(Duration timeout) { + try { + return timeout.toNanos(); + } catch (ArithmeticException ignored) { + return Long.MAX_VALUE; + } + } +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/CloseableBlockingQueue.java b/src/main/java/dev/azamir/primitives/concurrent/CloseableBlockingQueue.java new file mode 100644 index 0000000..bff10c8 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/CloseableBlockingQueue.java @@ -0,0 +1,53 @@ +package dev.azamir.primitives.concurrent; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * A bounded queue contract with explicit shutdown semantics. + * + *

Implementations reject writes after close, allow already accepted elements to drain, and wake or + * eventually release blocked callers when the queue lifecycle changes. + */ +public interface CloseableBlockingQueue extends AutoCloseable { + void put(E element) throws InterruptedException; + + boolean offer(E element, Duration timeout) throws InterruptedException; + + E take() throws InterruptedException; + + Optional poll(); + + Optional poll(Duration timeout) throws InterruptedException; + + Optional peek(); + + int drainTo(Collection target, int maxElements); + + int clear(); + + int closeNow(); + + boolean contains(Object value); + + int indexOf(Object value); + + List snapshot(); + + boolean isClosed(); + + int size(); + + default boolean isEmpty() { + return size() == 0; + } + + int capacity(); + + int remainingCapacity(); + + @Override + void close(); +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/CloseableQueues.java b/src/main/java/dev/azamir/primitives/concurrent/CloseableQueues.java new file mode 100644 index 0000000..7f2e9ac --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/CloseableQueues.java @@ -0,0 +1,24 @@ +package dev.azamir.primitives.concurrent; + +import java.time.Duration; +import java.util.Objects; + +/** Factory methods for the supported bounded queue wait strategies. */ +public final class CloseableQueues { + public static final Duration DEFAULT_POLLING_BACKOFF = Duration.ofMillis(10); + + private CloseableQueues() {} + + public static CloseableBlockingQueue bounded(int capacity, QueueWaitStrategy strategy) { + return bounded(capacity, strategy, DEFAULT_POLLING_BACKOFF); + } + + public static CloseableBlockingQueue bounded( + int capacity, QueueWaitStrategy strategy, Duration pollingBackoff) { + Objects.requireNonNull(strategy, "strategy"); + return switch (strategy) { + case CONDITION_SIGNALING -> new BoundedBlockingQueue<>(capacity); + case POLLING_BACKOFF -> new PollingBackoffQueue<>(capacity, pollingBackoff); + }; + } +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/PollingBackoffQueue.java b/src/main/java/dev/azamir/primitives/concurrent/PollingBackoffQueue.java new file mode 100644 index 0000000..d2a0c6d --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/PollingBackoffQueue.java @@ -0,0 +1,326 @@ +package dev.azamir.primitives.concurrent; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A bounded FIFO queue that uses configurable polling backoff instead of condition signaling. + * + *

This strategy is intentionally less efficient than {@link BoundedBlockingQueue}, but it is useful + * when callers need a simple polling lifecycle or want to compare wait strategies. Closing the queue + * becomes visible to blocked callers no later than one backoff interval later. + */ +public final class PollingBackoffQueue implements CloseableBlockingQueue { + private final Object[] elements; + private final ReentrantLock lock = new ReentrantLock(); + private final long backoffNanos; + + private int head; + private int tail; + private int size; + private boolean closed; + + public PollingBackoffQueue(int capacity, Duration backoff) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be greater than zero"); + } + Objects.requireNonNull(backoff, "backoff"); + if (backoff.isZero() || backoff.isNegative()) { + throw new IllegalArgumentException("backoff must be greater than zero"); + } + elements = new Object[capacity]; + backoffNanos = toNanosSaturated(backoff); + } + + @Override + public void put(E element) throws InterruptedException { + Objects.requireNonNull(element, "element"); + while (true) { + lock.lockInterruptibly(); + try { + ensureOpenForWrite(); + if (size < elements.length) { + enqueue(element); + return; + } + } finally { + lock.unlock(); + } + pause(backoffNanos); + } + } + + @Override + public boolean offer(E element, Duration timeout) throws InterruptedException { + Objects.requireNonNull(element, "element"); + long remaining = validateTimeout(timeout); + while (true) { + long started = System.nanoTime(); + lock.lockInterruptibly(); + try { + ensureOpenForWrite(); + if (size < elements.length) { + enqueue(element); + return true; + } + } finally { + lock.unlock(); + } + if (remaining <= 0) { + return false; + } + pause(Math.min(backoffNanos, remaining)); + remaining -= elapsedSince(started); + } + } + + @Override + public E take() throws InterruptedException { + while (true) { + lock.lockInterruptibly(); + try { + if (size > 0) { + return dequeue(); + } + if (closed) { + throw new QueueClosedException("queue is closed and drained"); + } + } finally { + lock.unlock(); + } + pause(backoffNanos); + } + } + + @Override + public Optional poll() { + lock.lock(); + try { + return size == 0 ? Optional.empty() : Optional.of(dequeue()); + } finally { + lock.unlock(); + } + } + + @Override + public Optional poll(Duration timeout) throws InterruptedException { + long remaining = validateTimeout(timeout); + while (true) { + long started = System.nanoTime(); + lock.lockInterruptibly(); + try { + if (size > 0) { + return Optional.of(dequeue()); + } + if (closed) { + return Optional.empty(); + } + } finally { + lock.unlock(); + } + if (remaining <= 0) { + return Optional.empty(); + } + pause(Math.min(backoffNanos, remaining)); + remaining -= elapsedSince(started); + } + } + + @Override + public Optional peek() { + lock.lock(); + try { + return size == 0 ? Optional.empty() : Optional.of(elementAt(0)); + } finally { + lock.unlock(); + } + } + + @Override + public int drainTo(Collection target, int maxElements) { + Objects.requireNonNull(target, "target"); + if (maxElements < 0) { + throw new IllegalArgumentException("maxElements must not be negative"); + } + lock.lock(); + try { + int drained = Math.min(size, maxElements); + for (int i = 0; i < drained; i++) { + target.add(dequeue()); + } + return drained; + } finally { + lock.unlock(); + } + } + + @Override + public int clear() { + lock.lock(); + try { + return clearElements(); + } finally { + lock.unlock(); + } + } + + @Override + public int closeNow() { + lock.lock(); + try { + closed = true; + return clearElements(); + } finally { + lock.unlock(); + } + } + + @Override + public boolean contains(Object value) { + return indexOf(value) >= 0; + } + + @Override + public int indexOf(Object value) { + lock.lock(); + try { + for (int i = 0; i < size; i++) { + if (Objects.equals(value, elements[(head + i) % elements.length])) { + return i; + } + } + return -1; + } finally { + lock.unlock(); + } + } + + @Override + public List snapshot() { + lock.lock(); + try { + List copy = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + copy.add(elementAt(i)); + } + return List.copyOf(copy); + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + lock.lock(); + try { + closed = true; + } finally { + lock.unlock(); + } + } + + @Override + public boolean isClosed() { + lock.lock(); + try { + return closed; + } finally { + lock.unlock(); + } + } + + @Override + public int size() { + lock.lock(); + try { + return size; + } finally { + lock.unlock(); + } + } + + @Override + public int capacity() { + return elements.length; + } + + @Override + public int remainingCapacity() { + lock.lock(); + try { + return elements.length - size; + } finally { + lock.unlock(); + } + } + + private void ensureOpenForWrite() { + if (closed) { + throw new QueueClosedException("queue is closed for writes"); + } + } + + private void enqueue(E element) { + elements[tail] = element; + tail = (tail + 1) % elements.length; + size++; + } + + @SuppressWarnings("unchecked") + private E dequeue() { + E element = (E) elements[head]; + elements[head] = null; + head = (head + 1) % elements.length; + size--; + return element; + } + + private int clearElements() { + int removed = size; + for (int i = 0; i < removed; i++) { + elements[(head + i) % elements.length] = null; + } + head = 0; + tail = 0; + size = 0; + return removed; + } + + @SuppressWarnings("unchecked") + private E elementAt(int logicalIndex) { + return (E) elements[(head + logicalIndex) % elements.length]; + } + + private static long validateTimeout(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("timeout must not be negative"); + } + return toNanosSaturated(timeout); + } + + private static void pause(long nanos) throws InterruptedException { + if (nanos > 0) { + TimeUnit.NANOSECONDS.sleep(nanos); + } + } + + private static long elapsedSince(long started) { + long elapsed = System.nanoTime() - started; + return Math.max(elapsed, 1); + } + + private static long toNanosSaturated(Duration duration) { + try { + return duration.toNanos(); + } catch (ArithmeticException ignored) { + return Long.MAX_VALUE; + } + } +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/QueueClosedException.java b/src/main/java/dev/azamir/primitives/concurrent/QueueClosedException.java new file mode 100644 index 0000000..ec36c78 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/QueueClosedException.java @@ -0,0 +1,8 @@ +package dev.azamir.primitives.concurrent; + +/** Signals that an operation cannot continue because a queue has been closed. */ +public final class QueueClosedException extends IllegalStateException { + public QueueClosedException(String message) { + super(message); + } +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/QueueWaitStrategy.java b/src/main/java/dev/azamir/primitives/concurrent/QueueWaitStrategy.java new file mode 100644 index 0000000..9a642e5 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/QueueWaitStrategy.java @@ -0,0 +1,7 @@ +package dev.azamir.primitives.concurrent; + +/** Selects how a bounded queue waits for capacity or data. */ +public enum QueueWaitStrategy { + CONDITION_SIGNALING, + POLLING_BACKOFF +} diff --git a/src/main/java/dev/azamir/primitives/concurrent/ResettableLongAccumulator.java b/src/main/java/dev/azamir/primitives/concurrent/ResettableLongAccumulator.java new file mode 100644 index 0000000..414a8ac --- /dev/null +++ b/src/main/java/dev/azamir/primitives/concurrent/ResettableLongAccumulator.java @@ -0,0 +1,47 @@ +package dev.azamir.primitives.concurrent; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongBinaryOperator; + +/** + * A lock-free long accumulator with an atomic reset operation. + * + *

The supplied function should be side-effect free and associative because it may be invoked multiple + * times when concurrent updates retry. + */ +public final class ResettableLongAccumulator { + private final LongBinaryOperator function; + private final long identity; + private final AtomicLong value; + + public ResettableLongAccumulator(LongBinaryOperator function, long identity) { + this.function = Objects.requireNonNull(function, "function"); + this.identity = identity; + value = new AtomicLong(identity); + } + + public static ResettableLongAccumulator sum() { + return new ResettableLongAccumulator(Long::sum, 0); + } + + public static ResettableLongAccumulator max(long identity) { + return new ResettableLongAccumulator(Math::max, identity); + } + + public long accumulate(long operand) { + return value.accumulateAndGet(operand, function); + } + + public long get() { + return value.get(); + } + + public long getAndReset() { + return value.getAndSet(identity); + } + + public void reset() { + value.set(identity); + } +} diff --git a/src/main/java/dev/azamir/primitives/math/DecimalRatioCalculator.java b/src/main/java/dev/azamir/primitives/math/DecimalRatioCalculator.java new file mode 100644 index 0000000..b1a40db --- /dev/null +++ b/src/main/java/dev/azamir/primitives/math/DecimalRatioCalculator.java @@ -0,0 +1,60 @@ +package dev.azamir.primitives.math; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +/** + * Calculates decimal ratios with explicit scale, rounding, and an optional additive adjustment. + * + *

The adjustment is applied after division, matching use cases where a calibrated factor must be + * added to a measured ratio. + */ +public final class DecimalRatioCalculator { + private final BigDecimal adjustment; + + public DecimalRatioCalculator() { + this(BigDecimal.ZERO); + } + + public DecimalRatioCalculator(BigDecimal adjustment) { + this.adjustment = Objects.requireNonNull(adjustment, "adjustment"); + } + + public BigDecimal calculate(BigDecimal numerator, BigDecimal denominator) { + return calculate(numerator, denominator, 1, RoundingMode.UP); + } + + public BigDecimal calculate(BigDecimal numerator, BigDecimal denominator, int scale) { + return calculate(numerator, denominator, scale, RoundingMode.UP); + } + + public BigDecimal calculate( + BigDecimal numerator, BigDecimal denominator, RoundingMode roundingMode) { + return calculate(numerator, denominator, 1, roundingMode); + } + + public BigDecimal calculate( + BigDecimal numerator, BigDecimal denominator, int scale, RoundingMode roundingMode) { + Objects.requireNonNull(numerator, "numerator"); + Objects.requireNonNull(denominator, "denominator"); + Objects.requireNonNull(roundingMode, "roundingMode"); + if (scale < 0) { + throw new IllegalArgumentException("scale must not be negative"); + } + if (denominator.signum() == 0) { + throw new ArithmeticException("denominator must not be zero"); + } + return numerator.divide(denominator, scale, roundingMode).add(adjustment); + } + + public double calculate(double numerator, double denominator, int scale, RoundingMode roundingMode) { + return calculate( + BigDecimal.valueOf(numerator), BigDecimal.valueOf(denominator), scale, roundingMode) + .doubleValue(); + } + + public BigDecimal adjustment() { + return adjustment; + } +} diff --git a/src/main/java/dev/azamir/primitives/statistics/StatisticsSnapshot.java b/src/main/java/dev/azamir/primitives/statistics/StatisticsSnapshot.java new file mode 100644 index 0000000..d1d2ff9 --- /dev/null +++ b/src/main/java/dev/azamir/primitives/statistics/StatisticsSnapshot.java @@ -0,0 +1,13 @@ +package dev.azamir.primitives.statistics; + +import java.util.OptionalDouble; + +/** Immutable view of a streaming statistics accumulator. */ +public record StatisticsSnapshot( + long count, + double sum, + OptionalDouble mean, + OptionalDouble sampleVariance, + OptionalDouble sampleStandardDeviation, + OptionalDouble min, + OptionalDouble max) {} diff --git a/src/main/java/dev/azamir/primitives/statistics/StreamingStatistics.java b/src/main/java/dev/azamir/primitives/statistics/StreamingStatistics.java new file mode 100644 index 0000000..d9cc82f --- /dev/null +++ b/src/main/java/dev/azamir/primitives/statistics/StreamingStatistics.java @@ -0,0 +1,98 @@ +package dev.azamir.primitives.statistics; + +import java.util.OptionalDouble; + +/** + * Thread-safe one-pass descriptive statistics using Welford's algorithm for variance and compensated + * summation for improved numerical stability. + */ +public final class StreamingStatistics { + private long count; + private double mean; + private double squaredDeviationSum; + private double sum; + private double sumCompensation; + private double min = Double.POSITIVE_INFINITY; + private double max = Double.NEGATIVE_INFINITY; + + public synchronized void add(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("value must be finite"); + } + + count++; + double delta = value - mean; + mean += delta / count; + double adjustedDelta = value - mean; + squaredDeviationSum += delta * adjustedDelta; + + double compensatedValue = value - sumCompensation; + double updatedSum = sum + compensatedValue; + sumCompensation = (updatedSum - sum) - compensatedValue; + sum = updatedSum; + + min = Math.min(min, value); + max = Math.max(max, value); + } + + public synchronized StatisticsSnapshot snapshot() { + OptionalDouble currentMean = count == 0 ? OptionalDouble.empty() : OptionalDouble.of(mean); + OptionalDouble variance = + count < 2 ? OptionalDouble.empty() : OptionalDouble.of(squaredDeviationSum / (count - 1)); + OptionalDouble standardDeviation = + variance.isEmpty() ? OptionalDouble.empty() : OptionalDouble.of(Math.sqrt(variance.getAsDouble())); + OptionalDouble currentMin = count == 0 ? OptionalDouble.empty() : OptionalDouble.of(min); + OptionalDouble currentMax = count == 0 ? OptionalDouble.empty() : OptionalDouble.of(max); + + return new StatisticsSnapshot( + count, sum, currentMean, variance, standardDeviation, currentMin, currentMax); + } + + public synchronized long count() { + return count; + } + + public synchronized double sum() { + return sum; + } + + public synchronized OptionalDouble mean() { + return count == 0 ? OptionalDouble.empty() : OptionalDouble.of(mean); + } + + public synchronized OptionalDouble sampleVariance() { + return count < 2 + ? OptionalDouble.empty() + : OptionalDouble.of(squaredDeviationSum / (count - 1)); + } + + public synchronized OptionalDouble sampleStandardDeviation() { + OptionalDouble variance = sampleVariance(); + return variance.isEmpty() + ? OptionalDouble.empty() + : OptionalDouble.of(Math.sqrt(variance.getAsDouble())); + } + + public synchronized OptionalDouble min() { + return count == 0 ? OptionalDouble.empty() : OptionalDouble.of(min); + } + + public synchronized OptionalDouble max() { + return count == 0 ? OptionalDouble.empty() : OptionalDouble.of(max); + } + + public synchronized void reset() { + count = 0; + mean = 0; + squaredDeviationSum = 0; + sum = 0; + sumCompensation = 0; + min = Double.POSITIVE_INFINITY; + max = Double.NEGATIVE_INFINITY; + } + + @Override + public synchronized String toString() { + return snapshot().toString(); + } +} diff --git a/src/test/java/dev/azamir/primitives/batching/AdaptiveBatchSizerTest.java b/src/test/java/dev/azamir/primitives/batching/AdaptiveBatchSizerTest.java new file mode 100644 index 0000000..5f9afa9 --- /dev/null +++ b/src/test/java/dev/azamir/primitives/batching/AdaptiveBatchSizerTest.java @@ -0,0 +1,64 @@ +package dev.azamir.primitives.batching; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class AdaptiveBatchSizerTest { + @Test + void estimatesRecordsFromPayloadAndFixedOverhead() { + AdaptiveBatchSizer sizer = new AdaptiveBatchSizer(1_000, 0.8); + + assertEquals(18, sizer.estimate(10, 500, 100)); + } + + @Test + void fallsBackToSampleSizeWhenOverheadOrPayloadPreventsProjection() { + AdaptiveBatchSizer sizer = new AdaptiveBatchSizer(1_000, 0.8); + + assertEquals(10, sizer.estimate(10, 1_200, 1_100)); + assertEquals(10, sizer.estimate(10, 100, 100)); + assertEquals(0, sizer.estimate(0, 0, 0)); + } + + @Test + void clampsProjectedBatchSize() { + AdaptiveBatchSizer sizer = new AdaptiveBatchSizer(10_000, 1.0, 5, 50); + + assertEquals(50, sizer.estimate(10, 20, 10)); + } + + @Test + void retriesTransientProbeFailuresBeforeEstimating() { + AtomicInteger attempts = new AtomicInteger(); + RetryingSizeProbe probe = + new RetryingSizeProbe<>( + ignored -> { + if (attempts.incrementAndGet() < 3) { + throw new IllegalStateException("temporary serialization failure"); + } + return 500; + }, + 3); + AdaptiveBatchSizer sizer = new AdaptiveBatchSizer(1_000, 0.8); + + assertEquals(18, sizer.estimate(10, "sample", probe, 100)); + assertEquals(3, attempts.get()); + } + + @Test + void reportsExhaustedRetryBudget() { + RetryingSizeProbe probe = + new RetryingSizeProbe<>(ignored -> { throw new IllegalStateException("failed"); }, 2); + + assertThrows(SizeProbeException.class, () -> probe.measureBytes("sample")); + } + + @Test + void rejectsInconsistentMeasurements() { + AdaptiveBatchSizer sizer = new AdaptiveBatchSizer(1_000, 0.8); + assertThrows(IllegalArgumentException.class, () -> sizer.estimate(10, 99, 100)); + } +} diff --git a/src/test/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueTest.java b/src/test/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueTest.java new file mode 100644 index 0000000..91e8ec5 --- /dev/null +++ b/src/test/java/dev/azamir/primitives/concurrent/BoundedBlockingQueueTest.java @@ -0,0 +1,212 @@ +package dev.azamir.primitives.concurrent; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; + +class BoundedBlockingQueueTest { + @Test + void preservesFifoOrderAndCapacityAccounting() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(3); + + queue.put(10); + queue.put(20); + queue.put(30); + + assertEquals(3, queue.size()); + assertEquals(0, queue.remainingCapacity()); + assertEquals(10, queue.take()); + assertEquals(20, queue.take()); + assertEquals(30, queue.take()); + assertEquals(0, queue.size()); + assertEquals(3, queue.remainingCapacity()); + } + + @Test + void producerWaitsUntilCapacityIsAvailable() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(1); + queue.put(1); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + try { + Future producer = + executor.submit( + () -> { + started.countDown(); + queue.put(2); + return null; + }); + + assertTrue(started.await(1, SECONDS)); + Thread.sleep(100); + assertFalse(producer.isDone()); + + assertEquals(1, queue.take()); + producer.get(2, SECONDS); + assertEquals(2, queue.take()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void consumerWaitsUntilAnElementArrives() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch started = new CountDownLatch(1); + try { + Future consumer = + executor.submit( + () -> { + started.countDown(); + return queue.take(); + }); + + assertTrue(started.await(1, SECONDS)); + Thread.sleep(100); + assertFalse(consumer.isDone()); + + queue.put(42); + assertEquals(42, consumer.get(2, SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void closeRejectsWritesButAllowsAcceptedElementsToDrain() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(2); + queue.put(1); + queue.put(2); + + queue.close(); + + assertTrue(queue.isClosed()); + assertThrows(QueueClosedException.class, () -> queue.put(3)); + assertEquals(1, queue.take()); + assertEquals(2, queue.take()); + assertThrows(QueueClosedException.class, queue::take); + assertTrue(queue.poll().isEmpty()); + } + + @Test + void closeWakesBlockedConsumers() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future consumer = executor.submit(queue::take); + Thread.sleep(100); + queue.close(); + + ExecutionException error = + assertThrows(ExecutionException.class, () -> consumer.get(2, SECONDS)); + assertInstanceOf(QueueClosedException.class, error.getCause()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void timedOperationsRespectTimeouts() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(1); + queue.put(1); + + assertFalse(queue.offer(2, Duration.ofMillis(30))); + assertEquals(1, queue.take()); + assertTrue(queue.poll(Duration.ofMillis(30)).isEmpty()); + } + + @Test + void drainToRemovesAtMostRequestedElements() throws Exception { + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(4); + queue.put(1); + queue.put(2); + queue.put(3); + List target = new ArrayList<>(); + + assertEquals(2, queue.drainTo(target, 2)); + assertEquals(List.of(1, 2), target); + assertEquals(1, queue.size()); + assertEquals(3, queue.take()); + } + + @Test + void concurrentProducersAndConsumersDoNotLoseOrDuplicateElements() throws Exception { + int producers = 4; + int consumers = 4; + int elementsPerProducer = 2_000; + int expected = producers * elementsPerProducer; + BoundedBlockingQueue queue = new BoundedBlockingQueue<>(128); + ExecutorService executor = Executors.newFixedThreadPool(producers + consumers); + Set consumed = ConcurrentHashMap.newKeySet(); + AtomicBoolean duplicate = new AtomicBoolean(); + List> consumerFutures = new ArrayList<>(); + List> producerFutures = new ArrayList<>(); + + try { + for (int i = 0; i < consumers; i++) { + consumerFutures.add( + executor.submit( + () -> { + try { + while (true) { + if (!consumed.add(queue.take())) { + duplicate.set(true); + } + } + } catch (QueueClosedException ignored) { + // Expected after all accepted elements are drained. + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + })); + } + + for (int producer = 0; producer < producers; producer++) { + int producerId = producer; + producerFutures.add( + executor.submit( + () -> { + for (int i = 0; i < elementsPerProducer; i++) { + queue.put(producerId * elementsPerProducer + i); + } + return null; + })); + } + + for (Future future : producerFutures) { + future.get(10, SECONDS); + } + queue.close(); + for (Future future : consumerFutures) { + future.get(10, SECONDS); + } + + assertFalse(duplicate.get()); + assertEquals(expected, consumed.size()); + for (int value = 0; value < expected; value++) { + assertTrue(consumed.contains(value)); + } + } finally { + queue.close(); + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/dev/azamir/primitives/concurrent/QueueStrategiesTest.java b/src/test/java/dev/azamir/primitives/concurrent/QueueStrategiesTest.java new file mode 100644 index 0000000..4c697f6 --- /dev/null +++ b/src/test/java/dev/azamir/primitives/concurrent/QueueStrategiesTest.java @@ -0,0 +1,104 @@ +package dev.azamir.primitives.concurrent; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class QueueStrategiesTest { + @Test + void factoryCreatesBothSupportedWaitStrategies() { + assertInstanceOf( + BoundedBlockingQueue.class, + CloseableQueues.bounded(4, QueueWaitStrategy.CONDITION_SIGNALING)); + assertInstanceOf( + PollingBackoffQueue.class, + CloseableQueues.bounded( + 4, QueueWaitStrategy.POLLING_BACKOFF, Duration.ofMillis(5))); + } + + @Test + void queueCollectionOperationsUseLogicalFifoOrder() throws Exception { + CloseableBlockingQueue queue = new BoundedBlockingQueue<>(4); + queue.put(10); + queue.put(20); + queue.put(30); + + assertFalse(queue.isEmpty()); + assertEquals(10, queue.peek().orElseThrow()); + assertEquals(List.of(10, 20, 30), queue.snapshot()); + assertTrue(queue.contains(20)); + assertEquals(1, queue.indexOf(20)); + assertEquals(-1, queue.indexOf(99)); + assertEquals(3, queue.clear()); + assertTrue(queue.isEmpty()); + assertEquals(List.of(), queue.snapshot()); + assertEquals(4, queue.remainingCapacity()); + } + + @Test + void closeNowAtomicallyDiscardsBufferedDataAndRejectsWrites() throws Exception { + CloseableBlockingQueue queue = new BoundedBlockingQueue<>(3); + queue.put(1); + queue.put(2); + + assertEquals(2, queue.closeNow()); + assertTrue(queue.isClosed()); + assertTrue(queue.isEmpty()); + assertTrue(queue.peek().isEmpty()); + assertThrows(QueueClosedException.class, () -> queue.put(3)); + assertThrows(QueueClosedException.class, queue::take); + } + + @Test + void pollingProducerWaitsUntilCapacityBecomesAvailable() throws Exception { + CloseableBlockingQueue queue = + new PollingBackoffQueue<>(1, Duration.ofMillis(5)); + queue.put(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future producer = + executor.submit( + () -> { + queue.put(2); + return null; + }); + Thread.sleep(30); + assertEquals(1, queue.take()); + producer.get(2, SECONDS); + assertEquals(2, queue.take()); + } finally { + queue.close(); + executor.shutdownNow(); + } + } + + @Test + void pollingCloseReleasesBlockedConsumerWithinBackoffInterval() throws Exception { + CloseableBlockingQueue queue = + new PollingBackoffQueue<>(1, Duration.ofMillis(5)); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future consumer = executor.submit(queue::take); + Thread.sleep(30); + queue.close(); + + ExecutionException failure = + assertThrows(ExecutionException.class, () -> consumer.get(2, SECONDS)); + assertInstanceOf(QueueClosedException.class, failure.getCause()); + } finally { + queue.close(); + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/dev/azamir/primitives/concurrent/ResettableLongAccumulatorTest.java b/src/test/java/dev/azamir/primitives/concurrent/ResettableLongAccumulatorTest.java new file mode 100644 index 0000000..b4182c4 --- /dev/null +++ b/src/test/java/dev/azamir/primitives/concurrent/ResettableLongAccumulatorTest.java @@ -0,0 +1,64 @@ +package dev.azamir.primitives.concurrent; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class ResettableLongAccumulatorTest { + @Test + void accumulatesAndResetsAtomically() { + ResettableLongAccumulator accumulator = ResettableLongAccumulator.sum(); + + accumulator.accumulate(10); + accumulator.accumulate(15); + + assertEquals(25, accumulator.get()); + assertEquals(25, accumulator.getAndReset()); + assertEquals(0, accumulator.get()); + } + + @Test + void supportsCustomAssociativeFunctions() { + ResettableLongAccumulator maximum = ResettableLongAccumulator.max(Long.MIN_VALUE); + + maximum.accumulate(10); + maximum.accumulate(4); + maximum.accumulate(30); + + assertEquals(30, maximum.get()); + } + + @Test + void concurrentUpdatesAreNotLost() throws Exception { + int workers = 8; + int incrementsPerWorker = 50_000; + ResettableLongAccumulator accumulator = ResettableLongAccumulator.sum(); + ExecutorService executor = Executors.newFixedThreadPool(workers); + List> futures = new ArrayList<>(); + + try { + for (int worker = 0; worker < workers; worker++) { + futures.add( + executor.submit( + () -> { + for (int i = 0; i < incrementsPerWorker; i++) { + accumulator.accumulate(1); + } + })); + } + for (Future future : futures) { + future.get(10, SECONDS); + } + + assertEquals((long) workers * incrementsPerWorker, accumulator.get()); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/src/test/java/dev/azamir/primitives/math/DecimalRatioCalculatorTest.java b/src/test/java/dev/azamir/primitives/math/DecimalRatioCalculatorTest.java new file mode 100644 index 0000000..5eb3a26 --- /dev/null +++ b/src/test/java/dev/azamir/primitives/math/DecimalRatioCalculatorTest.java @@ -0,0 +1,45 @@ +package dev.azamir.primitives.math; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import org.junit.jupiter.api.Test; + +class DecimalRatioCalculatorTest { + @Test + void appliesScaleRoundingAndAdjustment() { + DecimalRatioCalculator calculator = new DecimalRatioCalculator(new BigDecimal("0.10")); + + assertEquals( + new BigDecimal("0.77"), + calculator.calculate( + new BigDecimal("2"), new BigDecimal("3"), 2, RoundingMode.HALF_UP)); + } + + @Test + void preservesDefaultUpRoundingBehavior() { + DecimalRatioCalculator calculator = new DecimalRatioCalculator(); + assertEquals( + new BigDecimal("0.7"), + calculator.calculate(new BigDecimal("2"), new BigDecimal("3"))); + } + + @Test + void supportsDoubleConvenienceMethodWithoutBinaryConstructorArtifacts() { + DecimalRatioCalculator calculator = new DecimalRatioCalculator(new BigDecimal("0.05")); + assertEquals(0.72, calculator.calculate(2, 3, 2, RoundingMode.HALF_UP), 1e-12); + } + + @Test + void rejectsInvalidScaleAndZeroDenominator() { + DecimalRatioCalculator calculator = new DecimalRatioCalculator(); + assertThrows( + IllegalArgumentException.class, + () -> calculator.calculate(BigDecimal.ONE, BigDecimal.TEN, -1, RoundingMode.UP)); + assertThrows( + ArithmeticException.class, + () -> calculator.calculate(BigDecimal.ONE, BigDecimal.ZERO, 2, RoundingMode.UP)); + } +} diff --git a/src/test/java/dev/azamir/primitives/statistics/StreamingStatisticsTest.java b/src/test/java/dev/azamir/primitives/statistics/StreamingStatisticsTest.java new file mode 100644 index 0000000..0bc919e --- /dev/null +++ b/src/test/java/dev/azamir/primitives/statistics/StreamingStatisticsTest.java @@ -0,0 +1,84 @@ +package dev.azamir.primitives.statistics; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class StreamingStatisticsTest { + @Test + void calculatesStableOnePassStatistics() { + StreamingStatistics statistics = new StreamingStatistics(); + for (double value : new double[] {2, 4, 4, 4, 5, 5, 7, 9}) { + statistics.add(value); + } + + StatisticsSnapshot snapshot = statistics.snapshot(); + assertEquals(8, snapshot.count()); + assertEquals(40, snapshot.sum(), 1e-12); + assertEquals(5, snapshot.mean().orElseThrow(), 1e-12); + assertEquals(32.0 / 7.0, snapshot.sampleVariance().orElseThrow(), 1e-12); + assertEquals(Math.sqrt(32.0 / 7.0), snapshot.sampleStandardDeviation().orElseThrow(), 1e-12); + assertEquals(2, snapshot.min().orElseThrow(), 1e-12); + assertEquals(9, snapshot.max().orElseThrow(), 1e-12); + } + + @Test + void exposesEmptyStateAndSupportsReset() { + StreamingStatistics statistics = new StreamingStatistics(); + assertTrue(statistics.mean().isEmpty()); + assertTrue(statistics.sampleVariance().isEmpty()); + assertTrue(statistics.min().isEmpty()); + assertTrue(statistics.max().isEmpty()); + + statistics.add(12.5); + statistics.reset(); + + assertEquals(0, statistics.count()); + assertEquals(0, statistics.sum(), 0); + assertTrue(statistics.snapshot().mean().isEmpty()); + } + + @Test + void rejectsNonFiniteValues() { + StreamingStatistics statistics = new StreamingStatistics(); + assertThrows(IllegalArgumentException.class, () -> statistics.add(Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> statistics.add(Double.POSITIVE_INFINITY)); + } + + @Test + void acceptsConcurrentUpdatesWithoutLosingValues() throws Exception { + StreamingStatistics statistics = new StreamingStatistics(); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + List> futures = + List.of( + executor.submit(() -> addRepeatedly(statistics, 1_000)), + executor.submit(() -> addRepeatedly(statistics, 1_000)), + executor.submit(() -> addRepeatedly(statistics, 1_000)), + executor.submit(() -> addRepeatedly(statistics, 1_000))); + for (Future future : futures) { + future.get(5, SECONDS); + } + + assertEquals(4_000, statistics.count()); + assertEquals(4_000, statistics.sum(), 1e-12); + assertEquals(1, statistics.mean().orElseThrow(), 1e-12); + assertEquals(0, statistics.sampleVariance().orElseThrow(), 1e-12); + } finally { + executor.shutdownNow(); + } + } + + private static void addRepeatedly(StreamingStatistics statistics, int count) { + for (int i = 0; i < count; i++) { + statistics.add(1); + } + } +}