diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml new file mode 100644 index 0000000..8987757 --- /dev/null +++ b/.buildkite/pipeline.yaml @@ -0,0 +1,18 @@ +--- +steps: + - label: ':go: Lint' + commands: + - golangci-lint run --timeout 10m0s + + - label: ':hammer: Test' + commands: + - gotestsum --junitfile test.xml ./... + plugins: + - test-collector#v1.10.0: + files: test.xml + format: junit + + - label: ':codecov: + :codeclimate: Coverage' + commands: + - go test -race -coverprofile=cover.out ./... + - sh .buildkite/upload_coverage.sh cover.out diff --git a/.buildkite/upload_coverage.sh b/.buildkite/upload_coverage.sh new file mode 100755 index 0000000..61116a7 --- /dev/null +++ b/.buildkite/upload_coverage.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +COVERAGE_REPORT="$1" +test -r "$COVERAGE_REPORT" || exit 1 + +echo "Upload Codecov Coverage" +codecov -f "$COVERAGE_REPORT" & +PID1=$! + +echo "Upload Code Climate Coverage" +cc-test-reporter format-coverage -t gocov -p fillmore-labs.com/exp/async -o .coverage/codeclimate.json "$COVERAGE_REPORT" +cc-test-reporter upload-coverage -r "$CC_TEST_REPORTER_ID" -i .coverage/codeclimate.json & +PID2=$! + +wait $PID1 $PID2 || true +echo "Coverage Upload Done" diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..9791151 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,14 @@ +--- +version: "2" +checks: + similar-code: + enabled: false + identical-code: + enabled: false +exclude_patterns: + - "**/.*" + - "**/*_test.go" + - "**/*.md" + - "go.mod" + - "go.sum" + - "LICENSE" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..156deea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +* text=auto +*.adoc text +*.bat text eol=crlf +*.bazel text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bzl text +*.css text eol=lf +*.env text +*.go text +*.html text eol=lf +*.jar filter=lfs diff=lfs merge=lfs -text +*.java text +*.js text eol=lf +*.json text +*.md text +*.patch text +*.png filter=lfs diff=lfs merge=lfs -text +*.proto text linguist-detectable +*.scala text +*.ts text eol=lf +*.yaml text +*.zip filter=lfs diff=lfs merge=lfs -text +go.mod text +go.sum text +BUILD text -linguist-detectable +WORKSPACE text -linguist-detectable diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..4931c82 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,7 @@ +--- +coverage: + status: + project: false + patch: false +ignore: + - internal/mocks diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..03cdc5a --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,8 @@ +{ + "automerge": true, + "automergeType": "branch", + "extends": [ + "config:base", + ":disableDependencyDashboard" + ] +} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..949533f --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,21 @@ +--- +name: Go +"on": + push: + branches: + - main + pull_request: + branches: + - main +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + check-latest: true + - name: Test + run: go test -race ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d240c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.* +!/.buildkite/ +!/.codeclimate.yml +!/.envrc +!/.gitattributes +!/.github/ +!/.gitignore +!/.golangci.yaml +!/.markdownlint.json +!/.mockery.yaml +!/.yamlfmt +!/.yamllint +/bin/ +/cover.out +/test.xml diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..8e85910 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,43 @@ +--- +run: + modules-download-mode: readonly +linters: + enable-all: true + disable: + # deprecated + - deadcode + - exhaustivestruct + - golint + - ifshort + - interfacer + - maligned + - nosnakecase + - scopelint + - structcheck + - varcheck + # disabled + - depguard + - exhaustruct + - forbidigo + - ireturn + - nonamedreturns + - varnamelen + - wrapcheck + - wsl +linters-settings: + testifylint: + enable: + - bool-compare + - compares + - empty + - error-is-as + - error-nil + - expected-actual + - float-compare + # - go-require + - len + # - nil-compare + # - require-error + - suite-dont-use-pkg + - suite-extra-assert-call + - suite-thelper diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..1504898 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,11 @@ +{ + "no-hard-tabs": { + "ignore_code_languages": [ + "go" + ], + "spaces_per_tab": 4 + }, + "line-length": { + "line_length": 120 + } +} diff --git a/.yamlfmt b/.yamlfmt new file mode 100644 index 0000000..135dd95 --- /dev/null +++ b/.yamlfmt @@ -0,0 +1,8 @@ +--- +formatter: + type: basic + include_document_start: true + retain_line_breaks: true + scan_folded_as_literal: true + max_line_length: 100 + pad_line_comments: 2 diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..8a110bb --- /dev/null +++ b/.yamllint @@ -0,0 +1,7 @@ +--- +extends: default +rules: + empty-lines: + max: 1 + line-length: + max: 120 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b16228f --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Async (Experimental) + +[![Go Reference](https://pkg.go.dev/badge/fillmore-labs.com/exp/async.svg)](https://pkg.go.dev/fillmore-labs.com/exp/async) +[![Build Status](https://badge.buildkite.com/06fc8f7bdcfc5c380ea0c7c8bb92a7cee8b1676b841f3c65c8.svg)](https://buildkite.com/fillmore-labs/async-exp) +[![Test Coverage](https://codecov.io/gh/fillmore-labs/async-exp/graph/badge.svg?token=GQUJA8PKJI)](https://codecov.io/gh/fillmore-labs/async-exp) +[![Maintainability](https://api.codeclimate.com/v1/badges/72fe9626fb821fc70251/maintainability)](https://codeclimate.com/github/fillmore-labs/async-exp/maintainability) +[![Go Report Card](https://goreportcard.com/badge/fillmore-labs.com/exp/async)](https://goreportcard.com/report/fillmore-labs.com/exp/async) +[![License](https://img.shields.io/github/license/fillmore-labs/exp-async)](https://github.com/fillmore-labs/exp-async/blob/main/LICENSE) + +The `async` package provides interfaces and utilities for writing asynchronous code in Go. + +## Motivation + +Futures and promises are constructs used for asynchronous and concurrent programming, allowing developers to work with +values that may not be immediately available and can be evaluated in a different execution context. + +Go is known for its built-in concurrency features like goroutines and channels. +The select statement further allows for efficient multiplexing and synchronization of multiple channels, thereby +enabling developers to coordinate and orchestrate asynchronous operations effectively. +Additionally, the context package offers a standardized way to manage cancellation, deadlines, and timeouts within +concurrent and asynchronous code. + +On the other hand, Go's error handling mechanism, based on explicit error values returned from functions, provides a +clear and concise way to handle errors. + +The purpose of this package is to provide a thin layer over channels which simplifies the integration of concurrent +code while providing a cohesive strategy for handling asynchronous errors. +By adhering to Go's standard conventions for asynchronous and concurrent code, as well as error propagation, this +package aims to enhance developer productivity and code reliability in scenarios requiring asynchronous operations. + +## Usage + +Assuming you have a synchronous function `func getMyIP(ctx context.Context) (string, error)` returning your external IP +address (see [GetMyIP](#getmyip) for an example). + +Now you can do + +```go + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + future := async.NewAsyncFuture(func() (string, error) { + return getMyIP(ctx) + }) +``` + +and elsewhere in your program, even in a different goroutine + +```go + if ip, err := future.Wait(ctx); err == nil { + log.Printf("My IP is %s", ip) + } else { + log.Printf("Error fetching IP: %v", err) + } +``` + +decoupling query construction from result processing. + +### GetMyIP + +Sample code to retrieve your IP address: + +```go +const ( + serverURL = "https://httpbin.org/ip" + timeout = 2 * time.Second +) + +type IPResponse struct { + Origin string `json:"origin"` +} + +func getMyIP(ctx context.Context) (string, error) { + resp, err := sendRequest(ctx) + if err != nil { + return "", err + } + + ipResponse, err := decodeResponse(resp) + if err != nil { + return "", err + } + + return ipResponse.Origin, nil +} + +func sendRequest(ctx context.Context) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + return http.DefaultClient.Do(req) +} + +func decodeResponse(response *http.Response) (*IPResponse, error) { + body, err := io.ReadAll(response.Body) + _ = response.Body.Close() + if err != nil { + return nil, err + } + + ipResponse := &IPResponse{} + err = json.Unmarshal(body, ipResponse) + if err != nil { + return nil, err + } + + return ipResponse, nil +} +``` + +## Concurrency Correctness + +When utilizing plain Go channels for concurrency, reasoning over the correctness of concurrent code becomes simpler +compared to some other implementations of futures and promises. +Channels provide a clear and explicit means of communication and synchronization between concurrent goroutines, making +it easier to understand and reason about the flow of data and control in a concurrent program. + +Therefore, this library provides a straightforward and idiomatic approach to achieving concurrency correctness. + +## Links + +- [Futures and Promises](https://en.wikipedia.org/wiki/Futures_and_promises) in the English Wikipedia diff --git a/async.go b/async.go new file mode 100644 index 0000000..d64a524 --- /dev/null +++ b/async.go @@ -0,0 +1,137 @@ +// Copyright 2023-2024 Oliver Eikemeier. All Rights Reserved. +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package async + +import "context" + +// Result defines the interface for returning results from asynchronous operations. +// It encapsulates the final value or error from the operation. +type Result[R any] interface { + V() (R, error) // The V method returns the final value or an error. +} + +// valueResult is an implementation of [Result] that simply holds a value. +type valueResult[R any] struct { + value R +} + +// V returns the stored value. +func (v valueResult[R]) V() (R, error) { + return v.value, nil +} + +// errorResult handles errors from failed operations. +type errorResult[R any] struct { + err error +} + +// V returns the stored error. +func (e errorResult[R]) V() (R, error) { + return *new(R), e.err +} + +// Promise is used to send the result of an asynchronous operation. +// +// It is a write-only channel. +// Either [Promise.SendValue] or [Promise.SendError] should be called exactly once. +type Promise[R any] chan<- Result[R] + +// Future represents an asynchronous operation that will complete sometime in the future. +// +// It is a read-only channel that can be used to retrieve the final result of a [Promise] with [Future.Wait]. +type Future[R any] <-chan Result[R] + +// NewFuture provides a simple way to create a Future for synchronous operations. +// This allows synchronous and asynchronous code to be composed seamlessly and separating initiation from waiting. +// +// - f takes a func that accepts a Promise as a [Promise] +// +// The returned [Future] that can be used to retrieve the eventual result of the [Promise]. +func NewFuture[R any](f func(promise Promise[R])) Future[R] { + ch := make(chan Result[R], 1) + f(ch) + + return ch +} + +// NewAsyncFuture runs f asynchronously, immediately returning a [Future] that can be used to retrieve the eventual +// result. This allows separating evaluating the result from computation. +func NewAsyncFuture[R any](f func() (R, error)) Future[R] { + return NewFuture(func(p Promise[R]) { go p.Send(f) }) +} + +// Send runs f synchronously, fulfilling the promise once it completes. +func (p Promise[R]) Send(f func() (R, error)) { + if value, err := f(); err == nil { + p.SendValue(value) + } else { + p.SendError(err) + } +} + +// SendValue fulfills the promise with a value once the operation completes. +func (p Promise[R]) SendValue(value R) { + p <- valueResult[R]{value: value} + close(p) +} + +// SendError breaks the promise with an error. +func (p Promise[R]) SendError(err error) { + p <- errorResult[R]{err: err} + close(p) +} + +// Wait returns the final result of the associated [Promise]. +// It can only be called once and blocks until a result is received or the context is canceled. +// If you need to read multiple times from a [Future] wrap it with [Future.Memoize]. +func (f Future[R]) Wait(ctx context.Context) (R, error) { + select { + case r, ok := <-f: + if !ok { + panic("expired future") + } + + return r.V() + + case <-ctx.Done(): + return *new(R), ctx.Err() + } +} + +// Awaitable is the underlying interface for [Future] and [Memoizer]. +// It blocks until a result is received or the context is canceled. +// Plain futures can only be queried once, while memoizers can be queried multiple times. +type Awaitable[R any] interface { + Wait(ctx context.Context) (R, error) +} + +// Then transforms the embedded result from an [Awaitable] using 'then'. +// This allows to easily handle errors embedded in the response. +// It blocks until a result is received or the context is canceled. +func Then[R, S any](ctx context.Context, f Awaitable[R], then func(R) (S, error)) (S, error) { + reply, err := f.Wait(ctx) + if err != nil { + return *new(S), err + } + + return then(reply) +} + +// ThenAsync asynchronously transforms the embedded result from an [Awaitable] using 'then'. +func ThenAsync[R, S any](ctx context.Context, f Awaitable[R], then func(R) (S, error)) Future[S] { + return NewAsyncFuture[S](func() (S, error) { return Then(ctx, f, then) }) +} diff --git a/async_test.go b/async_test.go new file mode 100644 index 0000000..07a31cb --- /dev/null +++ b/async_test.go @@ -0,0 +1,131 @@ +// Copyright 2023-2024 Oliver Eikemeier. All Rights Reserved. +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package async_test + +import ( + "context" + "errors" + "testing" + + "fillmore-labs.com/exp/async" + "github.com/stretchr/testify/suite" +) + +type PromiseTestSuite struct { + suite.Suite + promise async.Promise[int] + future async.Future[int] +} + +func TestResultChannelTestSuite(t *testing.T) { + t.Parallel() + suite.Run(t, new(PromiseTestSuite)) +} + +func (s *PromiseTestSuite) setPromise(promise async.Promise[int]) { s.promise = promise } + +func (s *PromiseTestSuite) SetupTest() { + s.future = async.NewFuture(s.setPromise) +} + +func (s *PromiseTestSuite) TestValue() { + // given + s.promise.SendValue(1) + + // when + value, err := s.future.Wait(context.Background()) + + // then + s.NoError(err) + s.Equal(1, value) +} + +var errTest = errors.New("test error") + +func (s *PromiseTestSuite) TestError() { + // given + s.promise.SendError(errTest) + + // when + _, err := s.future.Wait(context.Background()) + + // then + s.ErrorIs(err, errTest) +} + +func (s *PromiseTestSuite) TestCancellation() { + // given + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // when + _, err := s.future.Wait(ctx) + + // then + s.ErrorIs(err, context.Canceled) +} + +func (s *PromiseTestSuite) TestPanic() { + // given + s.promise.SendValue(1) + + // when + _, _ = s.future.Wait(context.Background()) + + // then + defer func() { _ = recover() }() + _, _ = s.future.Wait(context.Background()) // Should panic + s.Fail("did not panic") +} + +func add1(value int) (int, error) { return value + 1, nil } + +func (s *PromiseTestSuite) TestThen() { + // given + s.promise.SendValue(1) + + // when + value, err := async.Then[int, int](context.Background(), s.future, add1) + + // then + s.NoError(err) + s.Equal(2, value) +} + +func (s *PromiseTestSuite) TestThenError() { + // given + s.promise.SendError(errTest) + + // when + _, err := async.Then[int, int](context.Background(), s.future, add1) + + // then + s.ErrorIs(err, errTest) +} + +func (s *PromiseTestSuite) TestThenAsync() { + // given + f := async.ThenAsync[int, int](context.Background(), s.future, add1) + s.promise.SendValue(1) + + // when + value, err := f.Wait(context.Background()) + + // then + s.NoError(err) + s.Equal(2, value) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..341ddd8 --- /dev/null +++ b/doc.go @@ -0,0 +1,18 @@ +// Copyright 2023-2024 Oliver Eikemeier. All Rights Reserved. +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +// Package async provides utilities for handling asynchronous operations and results. +package async diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5458117 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module fillmore-labs.com/exp/async + +go 1.21 + +toolchain go1.21.6 + +require github.com/stretchr/testify v1.8.4 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa4b6e6 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/memoizer.go b/memoizer.go new file mode 100644 index 0000000..98f1518 --- /dev/null +++ b/memoizer.go @@ -0,0 +1,79 @@ +// Copyright 2023-2024 Oliver Eikemeier. All Rights Reserved. +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package async + +import ( + "context" + "errors" +) + +// Memoizer caches results from a [Future] to enable multiple queries and avoid unnecessary recomputation. +type Memoizer[R any] interface { + Awaitable[R] + + // TryWait returns the cached result when ready, [ErrNotReady] otherwise. + TryWait() (R, error) +} + +type memoizer[R any] struct { + done chan struct{} // done signals when future has completed + future Future[R] // future is the [Future] being cached + value Result[R] // value will hold the cached result +} + +// Memoize creates a new [Memoizer], consuming the [Future]. +func (f Future[R]) Memoize() Memoizer[R] { + return &memoizer[R]{done: make(chan struct{}), future: f} +} + +// Result returns the cached result or blocks until a result is available or the context is canceled. +func (m *memoizer[R]) Wait(ctx context.Context) (R, error) { + select { // wait for future completion or context cancel + case <-ctx.Done(): + return *new(R), ctx.Err() + + case v, ok := <-m.future: + if ok { + m.value = v + close(m.done) + } else { + <-m.done + } + } + + return m.value.V() +} + +var ErrNotReady = errors.New("future not ready") + +// TryWait returns the cached result when ready, [ErrNotReady] otherwise. +func (m *memoizer[R]) TryWait() (R, error) { + select { + default: + return *new(R), ErrNotReady + + case v, ok := <-m.future: + if ok { + m.value = v + close(m.done) + } else { + <-m.done + } + } + + return m.value.V() +} diff --git a/memoizer_test.go b/memoizer_test.go new file mode 100644 index 0000000..c18e47c --- /dev/null +++ b/memoizer_test.go @@ -0,0 +1,146 @@ +// Copyright 2023-2024 Oliver Eikemeier. All Rights Reserved. +// +// 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. +// +// SPDX-License-Identifier: Apache-2.0 + +package async_test + +import ( + "context" + "sync" + "testing" + "time" + + "fillmore-labs.com/exp/async" + "github.com/stretchr/testify/assert" +) + +func TestMemoizer(t *testing.T) { + t.Parallel() + + // given + f := async.NewAsyncFuture(func() (int, error) { return 1, nil }) + + // when + m := f.Memoize() + value, err := m.Wait(context.Background()) + + // then + if assert.NoError(t, err) { + assert.Equal(t, 1, value) + } +} + +func TestMemoizerError(t *testing.T) { + t.Parallel() + + // given + f := async.NewAsyncFuture(func() (int, error) { return 0, errTest }) + + // when + m := f.Memoize() + _, err := m.Wait(context.Background()) + + // then + assert.ErrorIs(t, err, errTest) +} + +func TestCancellation(t *testing.T) { + t.Parallel() + + // given + ctx, cancel := context.WithCancel(context.Background()) + cancel() + f := async.NewFuture(func(_ async.Promise[int]) {}) + + // when + m := f.Memoize() + _, err := m.Wait(ctx) + + // then + assert.ErrorIs(t, err, context.Canceled) +} + +func TestMultiple(t *testing.T) { + t.Parallel() + + // given + const iterations = 10 + + start := make(chan struct{}) + f := async.NewAsyncFuture(func() (int, error) { + <-start + + return 1, nil + }) + + // when + m := f.Memoize() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + var values [iterations]int + var errors [iterations]error + + var wg sync.WaitGroup + wg.Add(iterations) + for i := 0; i < iterations; i++ { + go func(i int) { + defer wg.Done() + values[i], errors[i] = m.Wait(ctx) + }(i) + } + close(start) + wg.Wait() + + // then + for i := 0; i < iterations; i++ { + if assert.NoError(t, errors[i]) { + assert.Equal(t, 1, values[i]) + } + } +} + +func TestTryWait(t *testing.T) { + t.Parallel() + + // given + start := make(chan struct{}) + done := make(chan struct{}) + f := async.NewAsyncFuture(func() (int, error) { + defer close(done) + <-start + + return 1, nil + }) + + // when + m := f.Memoize() + _, err1 := m.TryWait() + close(start) + <-done + + value2, err2 := m.TryWait() + value3, err3 := m.TryWait() + + // then + assert.ErrorIs(t, err1, async.ErrNotReady) + if assert.NoError(t, err2) { + assert.Equal(t, 1, value2) + } + if assert.NoError(t, err3) { + assert.Equal(t, 1, value3) + } +}