diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f1b219b..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 45d7424..d0470a0 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -12,13 +12,13 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.18', '1.19', '1.20', '1.21', '1.22'] + go: ['1.22', '1.23', '1.24', '1.25', '1.26'] steps: - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - run: go test -v -short -race -coverprofile=profile.cov ./... - name: Send coverage uses: shogo82148/actions-goveralls@v1 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 0a0b973..9f824d9 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -10,9 +10,12 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: 1.26 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v9 with: - version: latest + version: v2.12 \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index 9152b29..ba3ae90 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,33 +1,24 @@ -# Config file for golangci-lint +version: "2" -# options for analysis running -run: - # timeout for analysis, e.g. 30s, 5m, default is 1m. This has to be long - # enough to handle an empty cache on a slow machine. - timeout: 2m - - # include test files or not, default is true - tests: true - -# Run "golangci-lint linters" for a list of available linters. Don't enable -# any linters here, or they can't be disabled on the commandline. linters: - disable-all: true + default: none enable: - asciicheck - bodyclose - - err113 + - copyloopvar + - dogsled + - dupl - errcheck - errorlint - exhaustive - - exportloopref + - gocheckcompilerdirectives + - gochecknoinits - gocritic - - gofumpt - - goimports + - godox - goprintffuncname - gosec - - gosimple - govet + - intrange - ineffassign - makezero - misspell @@ -37,242 +28,149 @@ linters: - prealloc - predeclared - revive - - rowserrcheck - - sqlclosecheck - staticcheck - - tparallel - - typecheck + - testifylint - unconvert + - unparam - unused - wastedassign - - fast: false - -# all available settings of specific linters -linters-settings: - errcheck: - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; - # default is false: such cases aren't reported by default. - check-blank: false - - # List of functions to exclude from checking, where each entry is a single function to exclude. - # See https://github.com/kisielk/errcheck#excluding-functions for details. - # exclude-functions: - exhaustive: - # If enum-like constants don't use all cases in a switch statement, - # consider a default good enough. - default-signifies-exhaustive: true - gocritic: - # all checks list: https://github.com/go-critic/checkers - # Enable all checks by enabling all tags, then disable a few. - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - disabled-checks: - # Don't be aggressive with comments. - - commentedOutCode - # Catches legit uses of case checking. - - equalFold - # Many defers can be skipped when exiting. Could fix this with a log.Fatal replacement. - - exitAfterDefer - # Disagree with the style recommendations for these three. - - ifElseChain - - octalLiteral - - unnamedResult - - filepathJoin - - tooManyResultsChecker - settings: - captLocal: - paramsOnly: false - hugeParam: - # Allowing 512 byte parameters. - sizeThreshold: 512 - nestingReduce: - # How many nested blocks before suggesting early exit. - bodyWidth: 4 - rangeExprCopy: - # Avoid copying arrays larger than this in range statement. - sizeThreshold: 512 - rangeValCopy: - # Avoid copying range values larger than this on every iteration. - sizeThreshold: 128 - truncateCmp: - skipArchDependent: false - underef: - skipRecvDeref: false - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - local-prefixes: github.com/die-net/ - govet: - # Enable most of the non-default linters too. - enable: - - asmdecl - - assign - - atomic - - atomicalign - - bools - - buildtag - - cgocall - - composite - - copylock - - durationcheck - - errorsas - - findcall - - httpresponse - - ifaceassert - - loopclosure - - lostcancel - - nilfunc - - nilness - - printf - - shadow - - shift - - sortslice - - stdmethods - - stringintconv - - structtag - - testinggoroutine - - tests - - unmarshal - - unreachable - - unsafeptr - - unusedresult - disable: - # We need to fix a few tests that rely on this first. - - deepequalerrors - nakedret: - # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 - max-func-lines: 6 - nolintlint: - allow-unused: false - allow-leading-space: false - require-explanation: true - revive: - # Show all issues, not just those with a high confidence - confidence: 0.0 + - whitespace + + settings: + dupl: + threshold: 100 + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - unnamedResult + settings: + hugeParam: + sizeThreshold: 100 + godox: + keywords: + - FIXME + govet: + enable: + - asmdecl + - assign + - atomic + - atomicalign + - bools + - buildtag + - cgocall + - composites + - copylocks + - errorsas + - findcall + - httpresponse + - ifaceassert + - loopclosure + - lostcancel + - nilfunc + - nilness + - printf + - shadow + - shift + - sortslice + - stdmethods + - stringintconv + - structtag + - testinggoroutine + - tests + - unmarshal + - unreachable + - unsafeptr + - unusedresult + errorlint: + asserts: false + misspell: + locale: US + nolintlint: + allow-unused: false # report any unused nolint directives + require-explanation: true # require an explanation for nolint directives + require-specific: true # require nolint directives to be specific about which linter is being skipped + revive: + rules: + - name: atomic + - name: bare-return + - name: blank-imports + - name: confusing-naming + - name: context-as-argument + - name: context-keys-type + - name: datarace + - name: deep-exit + - name: defer + - name: dot-imports + - name: duplicated-imports + - name: early-return + - name: empty-block + - name: empty-block + - name: empty-lines + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: forbidden-call-in-wg-go + - name: get-return + - name: if-return + - name: import-shadowing + - name: increment-decrement + - name: indent-error-flow + - name: inefficient-map-lookup + - name: modifies-value-receiver + - name: package-directory-mismatch + - name: range-val-address + - name: range-val-in-closure + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: string-of-int + - name: struct-tag + - name: superfluous-else + - name: time-date + - name: time-equal + - name: time-naming + - name: unconditional-recursion + - name: unexported-return + - name: unnecessary-if + - name: unnecessary-format + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + - name: unused-receiver + - name: use-any + - name: use-errors-new + - name: use-waitgroup-go + - name: useless-break + - name: useless-fallthrough + - name: var-declaration + - name: var-naming + - name: waitgroup-by-value + exclusions: rules: - - name: atomic - # - name: bare-return - - name: blank-imports - # - name: confusing-naming - # - name: confusing-results - - name: constant-logical-expr - - name: context-as-argument - - name: context-keys-type - # - name: deep-exit - # - name: defer - - name: dot-imports - # - name: early-return - # - name: empty-block - - name: error-naming - - name: error-return - - name: error-strings - - name: errorf - - name: exported - # - name: get-return - - name: identical-branches - - name: if-return - - name: increment-decrement - - name: indent-error-flow - # - name: import-shadowing - - name: modifies-parameter - # - name: modifies-value-receiver - - name: package-comments - - name: range - - name: range-val-in-closure - - name: range-val-address - - name: receiver-naming - # - name: redefines-builtin-id - - name: string-of-int - # - name: struct-tag - - name: superfluous-else - - name: time-naming - # - name: var-naming - # - name: var-declaration - - name: unconditional-recursion - # - name: unexported-naming - - name: unexported-return - - name: unnecessary-stmt - - name: unreachable-code - # - name: unused-parameter - # - name: unused-receiver - - name: waitgroup-by-value - -# output configuration options -output: - # colored-line-number|line-number|json|tab|checkstyle, default is "colored-line-number" - formats: - - format: line-number - - # print lines of code with issue, default is true - print-issued-lines: false - - # sorts results by: filepath, line and column - sort-results: true - -issues: - # List of regexps of issue texts to exclude, empty list by default. - # But independently from this option we use default exclude patterns, - # it can be disabled by `exclude-use-default: false`. To list all - # excluded by default patterns execute `golangci-lint run --help` - exclude: - # revive: We don't require comments, only that they be properly formatted - - should have( a package)? comment - - # revive: Don't force variable scope changes - - (indent-error-flow|superfluous-else).*drop this else and outdent its block .move short variable declaration to its own line if necessary. - - # govet: Allow the most common form of shadowing - - declaration of .err. shadows declaration - - # gocritic: We use named Err return as part of our defer handling pattern. - - captLocal. .Err. should not be capitalized - - # govet: Allow an unused noCopy struct field to disallow copying - - .noCopy. is unused - - # gosec: Let errcheck complain about this instead - - G104. Errors unhandled - - # gosec: All URLs are variable in our code; this isn't useful - - G107. Potential HTTP request made with variable url - - # gosec: Complaining about every exec.Command() is annoying; we'll audit them - - G204. Subprocess launching should be audited - - G204. Subprocess launched with variable - - G204. Subprocess launched with function call as argument or cmd arguments - - # gosec: Too many false positives for legit uses of files and directories - - G301. Expect directory permissions to be 0750 or less - - G302. Expect file permissions to be 0600 or less - - G306. Expect WriteFile permissions to be 0600 or less - - # gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' - - G304. Potential file inclusion via variable - - # gosec: Complaining about every use of math/rand is annoying. - - G404. Use of weak random number generator - - # gosec: We're allowing SHA1 for now, but MD5, DES, and RC4 need to be audited - - G401. Use of weak cryptographic primitive - - G505. Blocklisted import crypto/sha1. weak cryptographic primitive - - # Exclude some linters from running on template-generated code, where we - # can't fix the output. - exclude-rules: - - # Independently from option `exclude` we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute `golangci-lint run --help`. - # Default value for this option is true. - exclude-use-default: false - - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. - max-issues-per-linter: 0 + - linters: + - govet + text: "shadow: declaration of \"err\" shadows declaration at" + - linters: + - errcheck + text: "Error return value of .*\\.Close. is not checked" + +formatters: + enable: + - gofumpt + - goimports + settings: + gofmt: + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + goimports: + local-prefixes: + - github.com/die-net/intervals - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. - max-same-issues: 0 diff --git a/README.md b/README.md index d39582f..bad77db 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,188 @@ Intervals [![Build Status](https://github.com/die-net/intervals/actions/workflows/go-test.yml/badge.svg)](https://github.com/die-net/intervals/actions/workflows/go-test.yml) [![Coverage Status](https://coveralls.io/repos/github/die-net/intervals/badge.svg?branch=main)](https://coveralls.io/github/die-net/intervals?branch=main) [![Go Report Card](https://goreportcard.com/badge/github.com/die-net/intervals)](https://goreportcard.com/report/github.com/die-net/intervals) ========= -Package intervals provides a fast insertion-sort based solution to the merge -overlapping intervals problem. - -An Interval represents a range of the form `[Start, End)`, which contains -all `x` in `Start <= x < End`. `x` can be any type that is ordered, -including the various sizes of `int`, `uint`, `float` types, `uintptr` and -`string`. - -The `Intervals` type is a slice of `Interval` as maintained by the `Insert()` -method, which keeps them in ascending order and with any two overlapping -`Interval` merged. Two `Interval` overlap if the `End` of one is `>=` the -`Start` from the next one. - -An example use case for this might be trying to do a concurrent download, -such as chunks of a large file from S3. You could kick off `n` goroutines, -each waiting to download `1/n` of the file, and use something like -`sync.WaitGroup` to wait until they are all done until you start to use -them. However, that would require waiting until the last byte is received -before start sending the first byte. If you want to start streaming the -answer from the beginning as soon as the relevant chunks are written to -disk, you could maintain a `done Intervals[int64]` and `done.Insert()` the -byte range written after each successful `Write()`, start streaming as soon -as `done.Search(0)` returns true, up to the whatever the returned Interval's -`End - 1` is. - -The overhead of `Insert()` is very low, with the worst case of `Insert()`ing -10,000 non-overlapping intervals only taking 147ns on my Apple Mac M1: +Package `intervals` provides a fast, insertion-sort based, zero-dependency +solution to the "merge overlapping intervals" problem. It keeps a slice of +ranges sorted and non-overlapping as you insert new ranges, and lets you +quickly look up which range (if any) contains a given value. +The package is generic: it works with any [ordered](https://pkg.go.dev/cmp#Ordered) +type, including the various sizes of `int`, `uint`, and `float`, as well as +`uintptr` and `string`. + +Concepts +-------- + +### Interval + +An `Interval[T]` represents a half-open range of the form `[Start, End)`, +which contains all `x` where `Start <= x < End`. Half-open ranges compose +cleanly: the `End` of one interval can equal the `Start` of the next without +the two values overlapping. + +```go +type Interval[T cmp.Ordered] struct { + Start T + End T +} +``` + +An interval is considered *empty* when `Start == End`. An interval where +`Start > End` is invalid and will panic when inserted. If `T` is a float, +neither bound may be `NaN` (because `NaN` is unordered); a `NaN` bound is +treated as empty. + +### Intervals + +`Intervals[T]` is a slice of `Interval[T]` kept in ascending order with no two +intervals overlapping. You build and maintain it through the `Insert` method, +which: + +- inserts a new interval in sorted position, or +- extends an adjacent or overlapping interval, or +- merges and compacts any intervals the new range now spans. + +Two intervals are merged when the `End` of one is `>=` the `Start` of the next, +so adjacent ranges like `[0, 2)` and `[2, 3)` collapse into `[0, 3)`. + +API +--- + +| Method | Description | +| --- | --- | +| `(Interval[T]) Empty() bool` | Reports whether the interval is empty (`Start == End`). Panics if `Start > End`. | +| `(Intervals[T]) Insert(v Interval[T]) Intervals[T]` | Returns a new `Intervals` with `v` added, keeping order and merging overlaps. An empty `v` is a no-op; an invalid `v` panics. | +| `(Intervals[T]) Search(off T) (Interval[T], bool)` | Returns the interval containing `off` and `true`, or the zero `Interval` and `false`. | + +Because `Insert` may reallocate the underlying slice (just like the built-in +`append`), always assign its result back: + +```go +done = done.Insert(iv) +``` + +Concurrency +----------- + +Neither `Insert` nor `Search` is safe for concurrent access. `Insert` mutates +the receiver, and `Search` requires the slice to remain correctly ordered while +it runs. If you share an `Intervals` across goroutines, guard it with a +`sync.RWMutex` (or equivalent): a write lock around `Insert`, and a read lock +around `Search`. + +Installation +------------ + +```sh +go get github.com/die-net/intervals +``` + +This package requires Go 1.22 or newer and pulls in no third-party +dependencies. + +Example +------- + +A motivating use case is a concurrent download, such as fetching chunks of a +large file from S3 with `n` goroutines. Rather than waiting for the last byte +before sending the first, you can record each completed byte range as it lands +and start streaming the contiguous prefix as soon as it is ready. + +```go +package main + +import ( + "fmt" + + "github.com/die-net/intervals" +) + +func main() { + var done intervals.Intervals[int64] + + // Chunks complete out of order; record each finished byte range. + done = done.Insert(intervals.Interval[int64]{Start: 0, End: 1024}) + done = done.Insert(intervals.Interval[int64]{Start: 2048, End: 4096}) + done = done.Insert(intervals.Interval[int64]{Start: 1024, End: 2048}) // fills the gap + + fmt.Println(done) // [{0 4096}] + + // How much contiguous data is ready starting at offset 0? + if iv, ok := done.Search(0); ok { + fmt.Printf("bytes [%d, %d) are ready to stream\n", iv.Start, iv.End) + } +} ``` -$ go test -cpu=1 -bench=. + +Performance +----------- + +Both `Insert` and `Search` use a binary search (`sort.Search`) to locate the +region of interest. `Insert` reuses the existing slice whenever possible, +copying in-place in the common cases, aggressively merging intervals where +possible and only allocating to double the size of the underlying slice when +it is full. `Search` doesn't allocate. + +The `Insert` benchmarks cover three scenarios: inserting disjoint intervals +(the worst case, since each must be kept separate), inserting mergeable +adjacent intervals, and inserting heavily overlapping intervals. + +The `Search` benchmarks cover the disjoint case (many separate intervals to +binary-search across) and the merged case (a single interval). `Search` is +read-only and runs in roughly logarithmic time in the number of unmerged +intervals remaining. + +``` +$ go test -cpu=1 -bench=. -benchmem goos: darwin goarch: arm64 pkg: github.com/die-net/intervals -BenchmarkInsertNonOverlapping/1 115100521 10.30 ns/op -BenchmarkInsertNonOverlapping/10 45857023 26.01 ns/op -BenchmarkInsertNonOverlapping/100 29844249 39.27 ns/op -BenchmarkInsertNonOverlapping/1000 21177404 57.57 ns/op -BenchmarkInsertNonOverlapping/10000 8115252 147.0 ns/op -BenchmarkInsertOverlapping/1 91738153 12.94 ns/op -BenchmarkInsertOverlapping/10 52171077 23.19 ns/op -BenchmarkInsertOverlapping/100 54989328 22.00 ns/op -BenchmarkInsertOverlapping/1000 47051826 25.75 ns/op -BenchmarkInsertOverlapping/10000 36945717 32.76 ns/op +cpu: Apple M2 Pro +BenchmarkInsertDisjoint/1 107374227 10.96 ns/op 91.23 MB/s 0 B/op 0 allocs/op +BenchmarkInsertDisjoint/10 45950748 26.01 ns/op 38.44 MB/s 0 B/op 0 allocs/op +BenchmarkInsertDisjoint/100 26269006 45.47 ns/op 21.99 MB/s 0 B/op 0 allocs/op +BenchmarkInsertDisjoint/1000 12517956 95.94 ns/op 10.42 MB/s 0 B/op 0 allocs/op +BenchmarkInsertDisjoint/10000 2251401 532.3 ns/op 1.88 MB/s 0 B/op 0 allocs/op +BenchmarkInsertMergeable/1 100000000 10.96 ns/op 91.22 MB/s 0 B/op 0 allocs/op +BenchmarkInsertMergeable/10 48691087 24.37 ns/op 41.04 MB/s 0 B/op 0 allocs/op +BenchmarkInsertMergeable/100 30892900 38.76 ns/op 25.80 MB/s 0 B/op 0 allocs/op +BenchmarkInsertMergeable/1000 19887729 60.10 ns/op 16.64 MB/s 0 B/op 0 allocs/op +BenchmarkInsertMergeable/10000 7792966 157.4 ns/op 6.35 MB/s 0 B/op 0 allocs/op +BenchmarkInsertOverlapping/1 70237390 16.43 ns/op 60.88 MB/s 0 B/op 0 allocs/op +BenchmarkInsertOverlapping/10 47493560 25.14 ns/op 39.78 MB/s 0 B/op 0 allocs/op +BenchmarkInsertOverlapping/100 48296054 24.92 ns/op 40.12 MB/s 0 B/op 0 allocs/op +BenchmarkInsertOverlapping/1000 40313540 29.73 ns/op 33.63 MB/s 0 B/op 0 allocs/op +BenchmarkInsertOverlapping/10000 30862311 38.81 ns/op 25.77 MB/s 0 B/op 0 allocs/op +BenchmarkSearchDisjoint/1 479366676 2.506 ns/op 399.11 MB/s 0 B/op 0 allocs/op +BenchmarkSearchDisjoint/10 279172508 4.494 ns/op 222.52 MB/s 0 B/op 0 allocs/op +BenchmarkSearchDisjoint/100 194244874 6.083 ns/op 164.39 MB/s 0 B/op 0 allocs/op +BenchmarkSearchDisjoint/1000 145232559 8.269 ns/op 120.94 MB/s 0 B/op 0 allocs/op +BenchmarkSearchDisjoint/10000 110869339 10.91 ns/op 91.70 MB/s 0 B/op 0 allocs/op +BenchmarkSearchMerged/1 477927547 2.552 ns/op 391.81 MB/s 0 B/op 0 allocs/op +BenchmarkSearchMerged/10 478269381 2.502 ns/op 399.74 MB/s 0 B/op 0 allocs/op +BenchmarkSearchMerged/100 479289055 2.503 ns/op 399.54 MB/s 0 B/op 0 allocs/op +BenchmarkSearchMerged/1000 477311384 2.504 ns/op 399.28 MB/s 0 B/op 0 allocs/op +BenchmarkSearchMerged/10000 475930947 2.507 ns/op 398.91 MB/s 0 B/op 0 allocs/op PASS ``` -And there's 100% test coverage, with a bunch of weird corner cases tested. +(Numbers above were measured on an Apple M2 Pro and will vary by machine.) + +Testing +------- + +The package has 100% statement coverage, including a randomized fuzz-style test +and a number of awkward corner cases (empty ranges, `NaN` bounds, gap filling, +and full replacement). + +```sh +go test -cover ./... +``` License ------- -Copyright 2021-2024 Aaron Hopkins and contributors +Copyright 2021-2026 Aaron Hopkins and contributors 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 diff --git a/go.mod b/go.mod index 1a6474e..9c459cd 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,3 @@ module github.com/die-net/intervals -go 1.18 - -require ( - github.com/stretchr/testify v1.9.0 - golang.org/x/exp v0.0.0-20240707233637-46b078467d37 -) - -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 -) +go 1.22 diff --git a/go.sum b/go.sum deleted file mode 100644 index e4cf63b..0000000 --- a/go.sum +++ /dev/null @@ -1,12 +0,0 @@ -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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= -golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -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/intervals.go b/intervals.go index 8c8ad1f..73d6d10 100644 --- a/intervals.go +++ b/intervals.go @@ -3,9 +3,8 @@ package intervals import ( + "cmp" "sort" - - "golang.org/x/exp/constraints" ) // Interval represents a range of the form `[Start, End)`, which contains @@ -14,7 +13,7 @@ import ( // // If T is a float, neither Start nor End can be NaN, since NaN is not // ordered. -type Interval[T constraints.Ordered] struct { +type Interval[T cmp.Ordered] struct { Start T End T } @@ -37,7 +36,7 @@ func (v Interval[T]) Empty() bool { // Intervals is an ascending ordered representation of a slice of // non-overlapping Interval. -type Intervals[T constraints.Ordered] []Interval[T] +type Intervals[T cmp.Ordered] []Interval[T] // Search will return the Interval in the given Intervals containing the // value of off and true if found. Otherwise, it will return an empty diff --git a/intervals_test.go b/intervals_test.go index 153a89c..a16da35 100644 --- a/intervals_test.go +++ b/intervals_test.go @@ -2,11 +2,10 @@ package intervals import ( "math" - "math/rand" + "math/rand/v2" + "slices" "strconv" "testing" - - "github.com/stretchr/testify/assert" ) func TestEmpty(t *testing.T) { @@ -30,8 +29,9 @@ func TestEmpty(t *testing.T) { } for _, test := range tests { - empty := test.interval.Empty() - assert.Equal(t, test.empty, empty) + if empty := test.interval.Empty(); empty != test.empty { + t.Errorf("Empty(%v) = %v, want %v", test.interval, empty, test.empty) + } } } @@ -54,8 +54,9 @@ func TestSearchExpected(t *testing.T) { for _, test := range tests { v, ok := test.intervals.Search(test.offset) - assert.Equal(t, test.expected, v) - assert.Equal(t, test.ok, ok) + if v != test.expected || ok != test.ok { + t.Errorf("%v.Search(%d) = (%v, %v), want (%v, %v)", test.intervals, test.offset, v, ok, test.expected, test.ok) + } } } @@ -85,19 +86,21 @@ func TestInsertExpected(t *testing.T) { for _, v := range test.inserts { vs = vs.Insert(Interval[int64]{v.Start, v.End}) } - assert.Equal(t, test.expected, vs) + if !slices.Equal(vs, test.expected) { + t.Errorf("inserting %v = %v, want %v", test.inserts, vs, test.expected) + } } } func TestInsertRandom(t *testing.T) { - for n := 0; n < 1000; n++ { + for range 1000 { vs := Intervals[int64]{} - count := rand.Intn(10) + 1 + count := rand.IntN(10) + 1 //nolint:gosec // Not security sensitive. start := int64(10000) end := int64(-1) - for i := 0; i < count; i++ { - s := rand.Int63n(1024) - e := s + rand.Int63n(128) + for range count { + s := rand.Int64N(1024) //nolint:gosec // Not security sensitive. + e := s + rand.Int64N(128) //nolint:gosec // Not security sensitive. v := Interval[int64]{s, e} vs = vs.Insert(v) @@ -109,24 +112,32 @@ func TestInsertRandom(t *testing.T) { if s < start { start = s } - assert.Equal(t, start, vs[0].Start) + if vs[0].Start != start { + t.Fatalf("vs[0].Start = %d, want %d", vs[0].Start, start) + } // Track our own idea of the last end and verify it. if e > end { end = e } - assert.Equal(t, end, vs[len(vs)-1].End) + if vs[len(vs)-1].End != end { + t.Fatalf("vs[%d].End = %d, want %d", len(vs)-1, vs[len(vs)-1].End, end) + } } - assert.LessOrEqual(t, len(vs), count) + if len(vs) > count { + t.Fatalf("len(vs) = %d, want <= %d", len(vs), count) + } - for i := 0; i < len(vs); i++ { + for i := range vs { // Make sure each of the records looks legit. - assert.Less(t, vs[i].Start, vs[i].End) + if vs[i].Start >= vs[i].End { + t.Fatalf("vs[%d] = %v, want Start < End", i, vs[i]) + } // And there's a gap to the next one. - if i < len(vs)-1 { - assert.Less(t, vs[i].End, vs[i+1].Start) + if i < len(vs)-1 && vs[i].End >= vs[i+1].Start { + t.Fatalf("vs[%d].End = %d >= vs[%d].Start = %d, want a gap", i, vs[i].End, i+1, vs[i+1].Start) } } } @@ -134,21 +145,37 @@ func TestInsertRandom(t *testing.T) { func TestInsertPanic(t *testing.T) { // End before start should panic. - assert.Panics(t, func() { Intervals[int64]{}.Insert(Interval[int64]{2, 1}) }) + assertPanics(t, func() { Intervals[int64]{}.Insert(Interval[int64]{2, 1}) }) } -func BenchmarkInsertNonOverlapping(b *testing.B) { - benchInsert(b, 1024, 0) +// assertPanics fails the test if fn does not panic when called. +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Error("expected panic, but function returned normally") + } + }() + fn() +} + +func BenchmarkInsertDisjoint(b *testing.B) { + benchInsert(b, 1024, 1, 0) +} + +func BenchmarkInsertMergeable(b *testing.B) { + benchInsert(b, 1024, 0, 0) } func BenchmarkInsertOverlapping(b *testing.B) { - benchInsert(b, 1024, 10240) + benchInsert(b, 1024, 0, 10240) } -func benchInsert(b *testing.B, step, overlap int64) { +func benchInsert(b *testing.B, step, gap, overlap int64) { b.ReportAllocs() for _, num := range []int{1, 10, 100, 1000, 10000} { b.Run(strconv.Itoa(num), func(sb *testing.B) { + sb.SetBytes(1) sb.RunParallel(func(pb *testing.PB) { ovs := make(Intervals[int64], 0, num) @@ -159,7 +186,7 @@ func benchInsert(b *testing.B, step, overlap int64) { n++ if n >= len(ivs) { ovs = ovs[:0] - randIntervals(ivs, step, overlap) + randIntervals(ivs, step, gap, overlap) n = 0 } @@ -170,22 +197,62 @@ func benchInsert(b *testing.B, step, overlap int64) { } } -func randIntervals(vs Intervals[int64], step, overlap int64) { +func randIntervals(vs Intervals[int64], step, gap, overlap int64) { s := int64(0) - for i := 0; i < len(vs); i++ { - l := rand.Int63n(step) + for i := range vs { + l := rand.Int64N(step) //nolint:gosec // Not security sensitive. e := s + l v := Interval[int64]{s, e} if overlap > 0 { - v.End += rand.Int63n(overlap) + v.End += rand.Int64N(overlap) //nolint:gosec // Not security sensitive. } vs[i] = v - s = e + s = e + gap } rand.Shuffle(len(vs), func(i, j int) { vs[i], vs[j] = vs[j], vs[i] }) } + +func BenchmarkSearchDisjoint(b *testing.B) { + benchSearch(b, 1024, 1, 0) +} + +func BenchmarkSearchMerged(b *testing.B) { + benchSearch(b, 1024, 0, 0) +} + +func benchSearch(b *testing.B, step, gap, overlap int64) { + b.ReportAllocs() + for _, num := range []int{1, 10, 100, 1000, 10000} { + b.Run(strconv.Itoa(num), func(sb *testing.B) { + sb.SetBytes(1) + sb.RunParallel(func(pb *testing.PB) { + ivs := make(Intervals[int64], num) + randIntervals(ivs, step, gap, overlap) + + ovs := make(Intervals[int64], 0, len(ivs)) + for _, iv := range ivs { + ovs = ovs.Insert(iv) + } + + start := ovs[0].Start + end := ovs[len(ovs)-1].End + + n := end + + for pb.Next() { + n++ + if n >= end { + n = start + } + + _, _ = ovs.Search(n) + } + }) + }) + } +}