├── go.mod ├── .gitignore ├── Dockerfile ├── cache_default.go ├── cmd └── feature │ ├── go.sum │ ├── disable.go │ ├── add.go │ ├── delete.go │ ├── enable.go │ ├── create.go │ ├── remove.go │ ├── benchmark.go │ ├── main.go │ ├── describe.go │ └── get.go ├── cache_darwin.go ├── cache_linux.go ├── Makefile ├── go.sum ├── .github └── workflows │ └── test.yml ├── CONTRIBUTING.md ├── LICENSE ├── collection.go ├── CODE_OF_CONDUCT.md ├── store.go ├── fs.go ├── tier_test.go ├── gate_test.go ├── fs_test.go ├── tier.go ├── cache_test.go ├── gate.go ├── cache.go └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/segmentio/feature 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/segmentio/cli v0.5.0 7 | github.com/segmentio/fs v1.0.0 8 | ) 9 | 10 | require gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | /feature 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | vendor/ 17 | 18 | # Emacs 19 | *~ 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17 as build 2 | WORKDIR /go/src/github.com/segmentio/feature 3 | COPY . . 4 | RUN CGO_ENABELD=0 go build -mod=vendor ./cmd/feature 5 | 6 | FROM debian 7 | COPY --from=build /go/src/github.com/segmentio/feature/feature /usr/local/bin/feature 8 | VOLUME /var/run/feature/feature.db 9 | ENV FEATURE_PATH=/var/run/feature/feature.db 10 | ENTRYPOINT ["feature"] 11 | -------------------------------------------------------------------------------- /cache_default.go: -------------------------------------------------------------------------------- 1 | // +build !darwin,!linux 2 | 3 | package feature 4 | 5 | import ( 6 | "io" 7 | "os" 8 | ) 9 | 10 | func mmap(f *os.File) ([]byte, error) { 11 | s, err := f.Stat() 12 | if err != nil { 13 | return nil, err 14 | } 15 | b := make([]byte, s.Size()) 16 | _, err = io.ReadFull(f, b) 17 | return b, err 18 | } 19 | 20 | func munmap([]byte) error { 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /cmd/feature/go.sum: -------------------------------------------------------------------------------- 1 | github.com/segmentio/cli v0.3.4 h1:fdghOrtrCL4WLVFlWHw85hrAeDqAP3r+3ahJR2YDtJ4= 2 | github.com/segmentio/cli v0.3.4/go.mod h1:+m0rKUSZsAc4BPzL3Cw9jergrTVzySBdBerJcC/qfes= 3 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 4 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= 5 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 6 | -------------------------------------------------------------------------------- /cache_darwin.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | func mmap(f *os.File) ([]byte, error) { 9 | s, err := f.Stat() 10 | if err != nil { 11 | return nil, err 12 | } 13 | fd, size := int(f.Fd()), int(s.Size()) 14 | if size == 0 { 15 | return nil, nil 16 | } 17 | return syscall.Mmap(fd, 0, size, syscall.PROT_READ, syscall.MAP_SHARED) 18 | } 19 | 20 | func munmap(b []byte) error { 21 | if b != nil { 22 | return syscall.Munmap(b) 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /cache_linux.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | func mmap(f *os.File) ([]byte, error) { 9 | s, err := f.Stat() 10 | if err != nil { 11 | return nil, err 12 | } 13 | fd, size := int(f.Fd()), int(s.Size()) 14 | if size == 0 { 15 | return nil, nil 16 | } 17 | return syscall.Mmap(fd, 0, size, syscall.PROT_READ, syscall.MAP_SHARED|syscall.MAP_POPULATE) 18 | } 19 | 20 | func munmap(b []byte) error { 21 | if b != nil { 22 | return syscall.Munmap(b) 23 | } 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | branch ?= $(shell git rev-parse --abbrev-ref HEAD) 2 | commit ?= $(shell git rev-parse --short=7 HEAD) 3 | version ?= $(subst /,-,$(branch))-$(commit) 4 | image ?= segment/feature:$(version) 5 | 6 | feature: vendor $(wildcard *.go) $(wildcard ./cmd/feature/*.go) 7 | CGO_ENABELD=0 go build -mod=vendor ./cmd/feature 8 | 9 | docker: vendor 10 | docker build -t feature . 11 | 12 | publish: docker 13 | docker tag feature $(image) 14 | docker push $(image) 15 | 16 | vendor: ./vendor/modules.txt 17 | 18 | ./vendor/modules.txt: go.mod go.sum 19 | go mod vendor 20 | 21 | .PHONY: docker publish vendor 22 | -------------------------------------------------------------------------------- /cmd/feature/disable.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/segmentio/feature" 7 | ) 8 | 9 | type disableConfig struct { 10 | commonConfig 11 | } 12 | 13 | func disable(config disableConfig, group group, tier tier, family family, gate gate, collection collection) error { 14 | return config.mount(func(path feature.MountPoint) error { 15 | t, err := path.OpenTier(string(group), string(tier)) 16 | if err != nil { 17 | if os.IsNotExist(err) { 18 | return nil 19 | } 20 | return err 21 | } 22 | defer t.Close() 23 | return t.EnableGate(string(family), string(gate), string(collection), 0, false) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/feature/add.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/segmentio/feature" 4 | 5 | type addConfig struct { 6 | commonConfig 7 | } 8 | 9 | func add(config addConfig, group group, tier tier, collection collection, ids []id) error { 10 | return config.mount(func(path feature.MountPoint) error { 11 | t, err := path.OpenTier(string(group), string(tier)) 12 | if err != nil { 13 | return err 14 | } 15 | defer t.Close() 16 | 17 | c, err := t.CreateCollection(string(collection)) 18 | if err != nil { 19 | return err 20 | } 21 | defer c.Close() 22 | 23 | for _, id := range ids { 24 | if err := c.Add(string(id)); err != nil { 25 | return err 26 | } 27 | } 28 | 29 | return c.Sync() 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/segmentio/cli v0.5.0 h1:AssNAdZV728i8u6LWfq9pqoeQGxiyXmTt0jrCfnjcx0= 2 | github.com/segmentio/cli v0.5.0/go.mod h1:rktB/5TnLUnEBYdRG+jlAii0bkHWpnrb+jpXiFkoPxs= 3 | github.com/segmentio/fs v1.0.0 h1:yGlbCdABc1MiWvDRifDiVpbyyZjddWY5VFSx7/54Rjw= 4 | github.com/segmentio/fs v1.0.0/go.mod h1:jNd0HEAtaU3kESmi2p6x+KX5xwN2ayP7l4DOrx3cKG0= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 8 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 9 | -------------------------------------------------------------------------------- /cmd/feature/delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/segmentio/feature" 4 | 5 | type deleteTierConfig struct { 6 | commonConfig 7 | } 8 | 9 | func deleteTier(config deleteTierConfig, group group, tier tier) error { 10 | return config.mount(func(path feature.MountPoint) error { 11 | return path.DeleteTier(string(group), string(tier)) 12 | }) 13 | } 14 | 15 | type deleteGateConfig struct { 16 | commonConfig 17 | } 18 | 19 | func deleteGate(config deleteGateConfig, group group, tier tier, family family, gate gate, collection collection) error { 20 | return config.mount(func(path feature.MountPoint) error { 21 | t, err := path.OpenTier(string(group), string(tier)) 22 | if err != nil { 23 | return err 24 | } 25 | defer t.Close() 26 | return t.DeleteGate(string(family), string(gate), string(collection)) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /cmd/feature/enable.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/segmentio/cli/human" 8 | "github.com/segmentio/feature" 9 | ) 10 | 11 | type enableConfig struct { 12 | commonConfig 13 | Open bool `flag:"-o,--open" help:"Sets the default state of the gate to open"` 14 | } 15 | 16 | func enable(config enableConfig, group group, tier tier, family family, gate gate, collection collection, volume human.Ratio) error { 17 | return config.mount(func(path feature.MountPoint) error { 18 | t, err := path.OpenTier(string(group), string(tier)) 19 | if err != nil { 20 | if os.IsNotExist(err) { 21 | return fmt.Errorf("%s/%s: tier does not exist\n", group, tier) 22 | } 23 | return err 24 | } 25 | defer t.Close() 26 | return t.EnableGate(string(family), string(gate), string(collection), float64(volume), config.Open) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | test-go115: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Go 11 | uses: actions/setup-go@v2 12 | with: 13 | go-version: 1.15 14 | - name: Test 15 | run: | 16 | go mod vendor 17 | go test -v ./... 18 | 19 | test-go116: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Go 24 | uses: actions/setup-go@v2 25 | with: 26 | go-version: 1.16 27 | - name: Test 28 | run: | 29 | go mod vendor 30 | go test -v ./... 31 | 32 | test-go117: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Set up Go 37 | uses: actions/setup-go@v2 38 | with: 39 | go-version: 1.17 40 | - name: Test 41 | run: | 42 | go mod vendor 43 | go test -v ./... 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to segmentio/feature 2 | 3 | ## Code of Conduct 4 | 5 | Help us keep the project open and inclusive. Please be kind to and 6 | considerate of other developers, as we all have the same goal: make 7 | the project as good as it can be. 8 | 9 | * [Code of Conduct](./CODE_OF_CONDUCT.md) 10 | 11 | ## Licensing 12 | 13 | All third party contributors acknowledge that any contributions they provide 14 | will be made under the same open source license that the open source project 15 | is provided under. 16 | 17 | ## Contributing 18 | 19 | * Open an Issue to report bugs or discuss non-trivial changes. 20 | * Open a Pull Request to submit a code change for review. 21 | 22 | ### Coding Rules 23 | 24 | To ensure consistency throughout the source code, keep these rules in mind 25 | when submitting contributions: 26 | 27 | * All features or bug fixes must be tested by one or more tests. 28 | * All exported types, functions, and symbols must be documented. 29 | * All code must be formatted with `go fmt`. 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2021 Twilio Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/feature/create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | 7 | "github.com/segmentio/feature" 8 | ) 9 | 10 | type createTierConfig struct { 11 | commonConfig 12 | } 13 | 14 | func createTier(config createTierConfig, group group, tier tier) error { 15 | return config.mount(func(path feature.MountPoint) error { 16 | t, err := path.CreateTier(string(group), string(tier)) 17 | if err != nil { 18 | return err 19 | } 20 | t.Close() 21 | return nil 22 | }) 23 | } 24 | 25 | type createGateConfig struct { 26 | commonConfig 27 | } 28 | 29 | func createGate(config createGateConfig, group group, tier tier, family family, gate gate, collection collection) error { 30 | return config.mount(func(path feature.MountPoint) error { 31 | var salt [4]byte 32 | 33 | if _, err := rand.Read(salt[:]); err != nil { 34 | return err 35 | } 36 | 37 | t, err := path.OpenTier(string(group), string(tier)) 38 | if err != nil { 39 | return err 40 | } 41 | defer t.Close() 42 | return t.CreateGate(string(family), string(gate), string(collection), binary.LittleEndian.Uint32(salt[:])) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/feature/remove.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/segmentio/feature" 10 | ) 11 | 12 | type removeConfig struct { 13 | commonConfig 14 | } 15 | 16 | func remove(config removeConfig, group group, tier tier, collection collection, ids []id) error { 17 | return config.mount(func(path feature.MountPoint) error { 18 | t, err := path.OpenTier(string(group), string(tier)) 19 | if err != nil { 20 | if os.IsNotExist(err) { 21 | return nil 22 | } 23 | return err 24 | } 25 | defer t.Close() 26 | 27 | c, err := t.OpenCollection(string(collection)) 28 | if err != nil { 29 | if os.IsNotExist(err) { 30 | return nil 31 | } 32 | return err 33 | } 34 | defer c.Close() 35 | 36 | if len(ids) == 0 { 37 | return nil 38 | } 39 | 40 | index := make(map[string]struct{}, len(ids)) 41 | for _, id := range ids { 42 | index[string(id)] = struct{}{} 43 | } 44 | 45 | list := make([]string, 0, 100) 46 | if err := feature.Scan(c.IDs(), func(id string) error { 47 | if _, rm := index[id]; !rm { 48 | list = append(list, id) 49 | } 50 | return nil 51 | }); err != nil { 52 | return err 53 | } 54 | 55 | filePath := c.Path() 56 | f, err := ioutil.TempFile(filepath.Dir(filePath), "."+filepath.Base(filePath)) 57 | if err != nil { 58 | return err 59 | } 60 | defer os.Remove(f.Name()) 61 | defer f.Close() 62 | w := bufio.NewWriter(f) 63 | 64 | for _, id := range list { 65 | w.WriteString(id) 66 | w.WriteString("\n") 67 | } 68 | 69 | if err := w.Flush(); err != nil { 70 | return err 71 | } 72 | 73 | return os.Rename(f.Name(), filePath) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/feature/benchmark.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "runtime/pprof" 8 | "time" 9 | 10 | "github.com/segmentio/cli/human" 11 | "github.com/segmentio/feature" 12 | ) 13 | 14 | type benchmarkConfig struct { 15 | commonConfig 16 | N int `flag:"-n,--count" help:"Number of iteration taken by the benchmark" default:"1000000"` 17 | CPUProfile human.Path `flag:"--cpu-profile" help:"Path where the CPU profile will be written" default:"-"` 18 | MemProfile human.Path `flag:"--mem-profile" help:"Path where the memory profile will be written" default:"-"` 19 | } 20 | 21 | func benchmark(config benchmarkConfig, family family, gate gate, collection collection, id id) error { 22 | if config.CPUProfile != "" { 23 | f, err := os.OpenFile(string(config.CPUProfile), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 24 | if err != nil { 25 | return err 26 | } 27 | defer f.Close() 28 | if err := pprof.StartCPUProfile(f); err != nil { 29 | return err 30 | } 31 | defer pprof.StopCPUProfile() 32 | } 33 | 34 | if config.MemProfile != "" { 35 | defer func() { 36 | f, err := os.OpenFile(string(config.MemProfile), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 37 | if err == nil { 38 | defer f.Close() 39 | pprof.WriteHeapProfile(f) 40 | } 41 | }() 42 | } 43 | 44 | return config.mount(func(path feature.MountPoint) error { 45 | c, err := path.Load() 46 | if err != nil { 47 | return err 48 | } 49 | defer c.Close() 50 | start := time.Now() 51 | io.WriteString(os.Stdout, "BenchmarkGateOpen") 52 | 53 | for i := 0; i < config.N; i++ { 54 | c.GateOpen(string(family), string(gate), string(collection), string(id)) 55 | } 56 | 57 | elapsed := time.Since(start) 58 | fmt.Printf("\t %d\t % 3d ns/op\n", config.N, int(float64(elapsed)/float64(config.N))) 59 | return nil 60 | }) 61 | } 62 | -------------------------------------------------------------------------------- /cmd/feature/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "text/tabwriter" 10 | 11 | "github.com/segmentio/cli" 12 | "github.com/segmentio/cli/human" 13 | "github.com/segmentio/feature" 14 | ) 15 | 16 | func main() { 17 | log.SetOutput(ioutil.Discard) 18 | cli.Exec(cli.CommandSet{ 19 | "benchmark": cli.Command(benchmark), 20 | "create": cli.CommandSet{ 21 | "gate": cli.Command(createGate), 22 | "tier": cli.Command(createTier), 23 | }, 24 | "delete": cli.CommandSet{ 25 | "gate": cli.Command(deleteGate), 26 | "tier": cli.Command(deleteTier), 27 | }, 28 | "get": cli.CommandSet{ 29 | "gates": cli.Command(getGates), 30 | "tiers": cli.Command(getTiers), 31 | }, 32 | "add": cli.Command(add), 33 | "remove": cli.Command(remove), 34 | "describe": cli.CommandSet{ 35 | "tier": cli.Command(describeTier), 36 | "collection": cli.Command(describeCollection), 37 | }, 38 | "enable": cli.Command(enable), 39 | "disable": cli.Command(disable), 40 | }) 41 | } 42 | 43 | type commonConfig struct { 44 | Path human.Path `flag:"-p,--path" help:"Path to the directory where the feature database is stored" default:"~/.feature"` 45 | } 46 | 47 | func (c *commonConfig) mount(do func(feature.MountPoint) error) error { 48 | p, err := feature.Mount(string(c.Path)) 49 | if err != nil { 50 | return err 51 | } 52 | return do(p) 53 | } 54 | 55 | type outputConfig struct { 56 | } 57 | 58 | func (c *outputConfig) buffered(do func(io.Writer) error) error { 59 | bw := bufio.NewWriter(os.Stdout) 60 | defer bw.Flush() 61 | return do(bw) 62 | } 63 | 64 | func (c *outputConfig) table(do func(io.Writer) error) error { 65 | tw := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0) 66 | defer tw.Flush() 67 | return do(tw) 68 | } 69 | 70 | type family string 71 | 72 | type group string 73 | 74 | type collection string 75 | 76 | type tier string 77 | 78 | type gate string 79 | 80 | type id string 81 | -------------------------------------------------------------------------------- /collection.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | type CollectionIter struct{ dir } 12 | 13 | func (it *CollectionIter) Close() error { return it.close() } 14 | 15 | func (it *CollectionIter) Next() bool { return it.next() } 16 | 17 | func (it *CollectionIter) Name() string { return it.name() } 18 | 19 | func (it *CollectionIter) IDs() *IDIter { 20 | return &IDIter{readfile(filepath.Join(it.path, it.name()))} 21 | } 22 | 23 | type IDIter struct{ file } 24 | 25 | func (it *IDIter) Close() error { return it.close() } 26 | 27 | func (it *IDIter) Next() bool { return it.next() } 28 | 29 | func (it *IDIter) Name() string { return it.line } 30 | 31 | type Collection struct { 32 | file *os.File 33 | buf *bufio.Writer 34 | err error 35 | } 36 | 37 | func (col *Collection) Path() string { 38 | if col.file != nil { 39 | return col.file.Name() 40 | } 41 | return "" 42 | } 43 | 44 | func (col *Collection) Close() error { 45 | if col.buf != nil { 46 | col.err = col.buf.Flush() 47 | col.buf = nil 48 | } 49 | if col.file != nil { 50 | col.file.Close() 51 | col.file = nil 52 | } 53 | return col.err 54 | } 55 | 56 | func (col *Collection) Sync() error { 57 | if col.buf != nil { 58 | return col.buf.Flush() 59 | } 60 | return nil 61 | } 62 | 63 | func (col *Collection) IDs() *IDIter { 64 | return &IDIter{col.ids()} 65 | } 66 | 67 | func (col *Collection) ids() file { 68 | if col.file != nil { 69 | return readfile(col.file.Name()) 70 | } 71 | return file{} 72 | } 73 | 74 | func (col *Collection) Add(id string) error { 75 | if col.err == nil { 76 | col.err = col.writeLine(id) 77 | } 78 | return col.err 79 | } 80 | 81 | func (col *Collection) writeLine(s string) error { 82 | if col.file == nil { 83 | return nil 84 | } 85 | if col.buf == nil { 86 | col.buf = bufio.NewWriter(col.file) 87 | } 88 | if _, err := col.buf.WriteString(s); err != nil { 89 | return err 90 | } 91 | return col.buf.WriteByte('\n') 92 | } 93 | 94 | type file struct { 95 | file *os.File 96 | buf *bufio.Reader 97 | line string 98 | err error 99 | } 100 | 101 | func (f *file) close() error { 102 | if f.file != nil { 103 | f.file.Close() 104 | } 105 | f.file, f.buf, f.line = nil, nil, "" 106 | return f.err 107 | } 108 | 109 | func (f *file) next() bool { 110 | if f.file == nil { 111 | return false 112 | } 113 | 114 | if f.buf == nil { 115 | f.buf = bufio.NewReader(f.file) 116 | } 117 | 118 | for { 119 | line, err := f.buf.ReadString('\n') 120 | 121 | if err != nil && (err != io.EOF || line == "") { 122 | if err == io.EOF { 123 | err = nil 124 | } 125 | f.err = err 126 | f.close() 127 | return false 128 | } 129 | 130 | if line = strings.TrimSuffix(line, "\n"); line != "" { 131 | f.line = line 132 | return true 133 | } 134 | } 135 | } 136 | 137 | func readfile(path string) file { 138 | f, err := os.Open(path) 139 | if err != nil { 140 | if os.IsNotExist(err) { 141 | err = nil 142 | } 143 | } 144 | return file{file: f, err: err} 145 | } 146 | -------------------------------------------------------------------------------- /cmd/feature/describe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/segmentio/feature" 9 | ) 10 | 11 | type describeCollectionConfig struct { 12 | commonConfig 13 | outputConfig 14 | Group string `flag:"-g,--group" help:"Group to include in the collection description" default:"-"` 15 | Tier string `flag:"-t,--tier" help:"Tier to include in the collection description" default:"-"` 16 | } 17 | 18 | func describeCollection(config describeCollectionConfig, collection collection) error { 19 | return config.mount(func(path feature.MountPoint) error { 20 | return config.buffered(func(w io.Writer) error { 21 | return feature.Scan(path.Groups(), func(group string) error { 22 | if config.Group != "" && config.Group != group { 23 | return nil 24 | } 25 | 26 | return feature.Scan(path.Tiers(group), func(tier string) error { 27 | if config.Tier != "" && config.Tier != tier { 28 | return nil 29 | } 30 | 31 | t, err := path.OpenTier(group, tier) 32 | if err != nil { 33 | return err 34 | } 35 | defer t.Close() 36 | 37 | return feature.Scan(t.IDs(string(collection)), func(id string) error { 38 | _, err := fmt.Fprintln(w, id) 39 | return err 40 | }) 41 | }) 42 | }) 43 | }) 44 | }) 45 | } 46 | 47 | type describeTierConfig struct { 48 | commonConfig 49 | outputConfig 50 | } 51 | 52 | func describeTier(config describeTierConfig, group group, tier tier) error { 53 | return config.mount(func(path feature.MountPoint) error { 54 | return config.buffered(func(w io.Writer) error { 55 | t, err := path.OpenTier(string(group), string(tier)) 56 | if err != nil { 57 | if os.IsNotExist(err) { 58 | return fmt.Errorf("%s/%s: tier does not exist\n", group, tier) 59 | } 60 | return err 61 | } 62 | defer t.Close() 63 | 64 | fmt.Fprintf(w, "Group:\t%s\n", group) 65 | fmt.Fprintf(w, "Tier:\t%s\n", tier) 66 | fmt.Fprint(w, "\nCollections:\n") 67 | 68 | if err := feature.Scan(t.Collections(), func(collection string) error { 69 | _, err := fmt.Fprintf(w, " - %s\n", collection) 70 | return err 71 | }); err != nil { 72 | return err 73 | } 74 | 75 | fmt.Fprintf(w, "\nGates:\n") 76 | 77 | if err := feature.Scan(t.Families(), func(family string) error { 78 | return feature.Scan(t.Gates(family), func(gate string) error { 79 | fmt.Fprintf(w, " %s/%s\n", family, gate) 80 | defer fmt.Fprintln(w) 81 | 82 | return feature.Scan(t.GatesCreated(family, gate), func(collection string) error { 83 | open, _, volume, err := t.ReadGate(family, gate, collection) 84 | if err != nil { 85 | return err 86 | } 87 | fmt.Fprintf(w, " - %s\t(%.0f%%, default: %s)\n", collection, volume*100, openFormat(open)) 88 | return nil 89 | }) 90 | }) 91 | }); err != nil { 92 | return err 93 | } 94 | 95 | return nil 96 | }) 97 | }) 98 | } 99 | 100 | type openFormat bool 101 | 102 | func (open openFormat) Format(w fmt.State, _ rune) { 103 | if open { 104 | io.WriteString(w, "open") 105 | } else { 106 | io.WriteString(w, "close") 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at open-source@twilio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "sync" 9 | "time" 10 | 11 | "github.com/segmentio/fs" 12 | ) 13 | 14 | // Store is similar to Cache, but automatically reloads when updates are made 15 | // to the underlying file system. 16 | type Store struct { 17 | cache Cache 18 | once sync.Once 19 | join sync.WaitGroup 20 | done chan struct{} 21 | notify chan string 22 | } 23 | 24 | // Close closes the store, releasing all associated resources. 25 | func (s *Store) Close() error { 26 | s.once.Do(func() { close(s.done) }) 27 | s.join.Wait() 28 | s.cache.Close() 29 | return nil 30 | } 31 | 32 | // GateOpen returns true if a gate is opened for a given id. 33 | func (s *Store) GateOpen(family, gate, collection, id string) bool { 34 | return s.cache.GateOpen(family, gate, collection, id) 35 | } 36 | 37 | // LookupGates returns the list of open gates in a family for a given id. 38 | func (s *Store) LookupGates(family, collection, id string) []string { 39 | return s.cache.LookupGates(family, collection, id) 40 | } 41 | 42 | // The Open method opens the features at the mount point it was called on, 43 | // returning a Store object exposing the state. 44 | // 45 | // The returned store holds operating system resources and therefore must be 46 | // closed when the program does not need it anymore. 47 | func (path MountPoint) Open() (*Store, error) { 48 | notify := make(chan string) 49 | 50 | if err := fs.Notify(notify, string(path)); err != nil { 51 | return nil, err 52 | } 53 | 54 | c, err := path.Load() 55 | if err != nil { 56 | fs.Stop(notify) 57 | return nil, err 58 | } 59 | 60 | s := &Store{ 61 | cache: Cache{tiers: c.tiers}, 62 | done: make(chan struct{}), 63 | notify: notify, 64 | } 65 | 66 | s.join.Add(1) 67 | go path.watch(s) 68 | return s, nil 69 | } 70 | 71 | func (path MountPoint) watch(s *Store) { 72 | defer s.join.Done() 73 | defer fs.Stop(s.notify) 74 | log.Printf("NOTICE feature - %s - watching for changes on the feature database", path) 75 | 76 | for { 77 | select { 78 | case <-s.notify: 79 | log.Printf("INFO feature - %s - reloading feature database after detecting update", path) 80 | if err := fs.Notify(s.notify, string(path)); err != nil { 81 | log.Printf("CRIT feature - %s - %s", path, err) 82 | } 83 | start := time.Now() 84 | c, err := path.Load() 85 | if err != nil { 86 | log.Printf("ERROR feature - %s - %s", path, err) 87 | } else { 88 | log.Printf("NOTICE feature - %s - feature database reloaded in %gs", path, time.Since(start).Round(time.Millisecond).Seconds()) 89 | c = s.cache.swap(c) 90 | c.Close() 91 | } 92 | case <-s.done: 93 | return 94 | } 95 | } 96 | } 97 | 98 | // Wait blocks until the path exists or ctx is cancelled. 99 | func (path MountPoint) Wait(ctx context.Context) error { 100 | notify := make(chan string) 101 | defer fs.Stop(notify) 102 | for { 103 | if err := fs.Notify(notify, filepath.Dir(string(path))); err != nil { 104 | return err 105 | } 106 | _, err := os.Lstat(string(path)) 107 | if err == nil { 108 | log.Printf("INFO feature - %s - feature database exists", path) 109 | return nil 110 | } 111 | if !os.IsNotExist(err) { 112 | return err 113 | } 114 | log.Printf("NOTICE feature - %s - waiting for feature database to be created", path) 115 | select { 116 | case <-notify: 117 | case <-ctx.Done(): 118 | return ctx.Err() 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /cmd/feature/get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "sort" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/segmentio/feature" 15 | "github.com/segmentio/fs" 16 | ) 17 | 18 | type getConfig struct { 19 | commonConfig 20 | Watch bool `flag:"-w,--watch" help:"Runs the command then blocks waiting for changes and runs the command again"` 21 | } 22 | 23 | func (c *getConfig) mount(do func(feature.MountPoint) error) error { 24 | return c.commonConfig.mount(func(path feature.MountPoint) error { 25 | if !c.Watch { 26 | return do(path) 27 | } 28 | 29 | if err := path.Wait(context.Background()); err != nil { 30 | return err 31 | } 32 | 33 | notify := make(chan string) 34 | defer fs.Stop(notify) 35 | 36 | sigch := make(chan os.Signal, 1) 37 | signal.Notify(sigch, syscall.SIGINT, syscall.SIGTERM) 38 | 39 | for { 40 | if err := fs.Notify(notify, string(path)); err != nil { 41 | return err 42 | } 43 | if err := do(path); err != nil { 44 | log.Print(err) 45 | } 46 | select { 47 | case <-notify: 48 | case <-sigch: 49 | return nil 50 | } 51 | } 52 | }) 53 | } 54 | 55 | type getTiersConfig struct { 56 | getConfig 57 | outputConfig 58 | } 59 | 60 | func getTiers(config getTiersConfig) error { 61 | return config.mount(func(path feature.MountPoint) error { 62 | return config.table(func(w io.Writer) error { 63 | fmt.Fprint(w, "GROUP\tTIER\tCOLLECTIONS\tFAMILIES\tGATES\n") 64 | return feature.Scan(path.Groups(), func(group string) error { 65 | return feature.Scan(path.Tiers(group), func(tier string) error { 66 | numCollections, numFamilies, numGates := 0, 0, 0 67 | 68 | t, err := path.OpenTier(group, tier) 69 | if err != nil { 70 | return err 71 | } 72 | defer t.Close() 73 | 74 | if err := feature.Scan(t.Collections(), func(string) error { 75 | numCollections++ 76 | return nil 77 | }); err != nil { 78 | return err 79 | } 80 | 81 | if err := feature.Scan(t.Families(), func(family string) error { 82 | numFamilies++ 83 | return feature.Scan(t.Gates(family), func(string) error { 84 | numGates++ 85 | return nil 86 | }) 87 | }); err != nil { 88 | return err 89 | } 90 | 91 | _, err = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%d\n", group, tier, numCollections, numFamilies, numGates) 92 | return err 93 | }) 94 | }) 95 | }) 96 | }) 97 | } 98 | 99 | type getGatesConfig struct { 100 | getConfig 101 | outputConfig 102 | } 103 | 104 | func getGates(config getGatesConfig, collection collection, id id) error { 105 | return config.mount(func(path feature.MountPoint) error { 106 | return config.table(func(w io.Writer) error { 107 | disabled := make(map[string]struct{}) 108 | enabled := make(map[string]struct{}) 109 | 110 | if err := feature.Scan(path.Groups(), func(group string) error { 111 | return feature.Scan(path.Tiers(group), func(tier string) error { 112 | t, err := path.OpenTier(group, tier) 113 | if err != nil { 114 | return err 115 | } 116 | defer t.Close() 117 | 118 | if err := feature.Scan(t.GatesEnabled(string(collection), string(id)), func(name string) error { 119 | enabled[name] = struct{}{} 120 | return nil 121 | }); err != nil { 122 | return err 123 | } 124 | 125 | if err := feature.Scan(t.GatesDisabled(string(collection), string(id)), func(name string) error { 126 | disabled[name] = struct{}{} 127 | return nil 128 | }); err != nil { 129 | return err 130 | } 131 | 132 | return nil 133 | }) 134 | }); err != nil { 135 | return err 136 | } 137 | 138 | for name := range disabled { 139 | delete(enabled, name) 140 | } 141 | 142 | if len(enabled) == 0 { 143 | return nil 144 | } 145 | 146 | list := make([]string, 0, len(enabled)) 147 | for name := range enabled { 148 | list = append(list, name) 149 | } 150 | sort.Strings(list) 151 | 152 | fmt.Fprint(w, "FAMILY\tGATE\n") 153 | for _, name := range list { 154 | if _, err := fmt.Fprintln(w, strings.ReplaceAll(name, "/", "\t")); err != nil { 155 | return err 156 | } 157 | } 158 | 159 | return nil 160 | }) 161 | }) 162 | } 163 | -------------------------------------------------------------------------------- /fs.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // Iter is an interface implemented by the iterator types exposed by this 11 | // package. 12 | type Iter interface { 13 | Close() error 14 | Next() bool 15 | Name() string 16 | } 17 | 18 | // Scan is a helper function used to iterate over each name exposed by an 19 | // iterator. 20 | func Scan(it Iter, do func(string) error) error { 21 | defer it.Close() 22 | 23 | for it.Next() { 24 | if err := do(it.Name()); err != nil { 25 | return err 26 | } 27 | } 28 | 29 | return it.Close() 30 | } 31 | 32 | // MountPoint represents the mount point of a feature file system. 33 | // 34 | // The type is a simple string alias, its value is the path to the root 35 | // directory of the file system. 36 | type MountPoint string 37 | 38 | func Mount(path string) (MountPoint, error) { 39 | p, err := filepath.Abs(path) 40 | return MountPoint(p), err 41 | } 42 | 43 | func (path MountPoint) CreateTier(group, name string) (*Tier, error) { 44 | if err := mkdir(path.groupPath(group)); err != nil { 45 | return nil, fmt.Errorf("creating tier group %q: %w", group, err) 46 | } 47 | if err := mkdir(path.tierPath(group, name)); err != nil { 48 | return nil, fmt.Errorf("creating tier %q of group %q: %w", name, group, err) 49 | } 50 | return &Tier{path: path, group: group, name: name}, nil 51 | } 52 | 53 | func (path MountPoint) OpenTier(group, name string) (*Tier, error) { 54 | _, err := os.Stat(path.tierPath(group, name)) 55 | if err != nil { 56 | return nil, err 57 | } 58 | return &Tier{path: path, group: group, name: name}, nil 59 | } 60 | 61 | func (path MountPoint) DeleteTier(group, name string) error { 62 | return rmdir(path.tierPath(group, name)) 63 | } 64 | 65 | func (path MountPoint) DeleteGroup(group string) error { 66 | return rmdir(path.groupPath(group)) 67 | } 68 | 69 | func (path MountPoint) Tiers(group string) *TierIter { 70 | return &TierIter{readdir(path.groupPath(group))} 71 | } 72 | 73 | func (path MountPoint) Groups() *GroupIter { 74 | return &GroupIter{readdir(string(path))} 75 | } 76 | 77 | func (path MountPoint) groupPath(group string) string { 78 | return filepath.Join(string(path), group) 79 | } 80 | 81 | func (path MountPoint) tierPath(group, name string) string { 82 | return filepath.Join(string(path), group, name) 83 | } 84 | 85 | type dir struct { 86 | file *os.File 87 | path string 88 | names []string 89 | index int 90 | err error 91 | } 92 | 93 | func (d *dir) opened() bool { 94 | return d.file != nil 95 | } 96 | 97 | func (d *dir) close() error { 98 | if d.file != nil { 99 | d.file.Close() 100 | d.file = nil 101 | } 102 | d.file = nil 103 | d.names = nil 104 | d.index = 0 105 | return d.err 106 | } 107 | 108 | func (d *dir) next() bool { 109 | if d.file == nil { 110 | return false 111 | } 112 | 113 | if d.index++; d.index < len(d.names) { 114 | return true 115 | } 116 | 117 | names, err := d.file.Readdirnames(100) 118 | switch err { 119 | case nil: 120 | d.names, d.index = names, 0 121 | return true 122 | case io.EOF: 123 | d.names, d.index = nil, 0 124 | return false 125 | default: 126 | d.err = err 127 | d.close() 128 | return false 129 | } 130 | } 131 | 132 | func (d *dir) name() string { 133 | if d.index >= 0 && d.index < len(d.names) { 134 | return d.names[d.index] 135 | } 136 | return "" 137 | } 138 | 139 | func (d *dir) read() dir { 140 | return readdir(filepath.Join(d.path, d.name())) 141 | } 142 | 143 | func mkdir(path string) error { 144 | err := os.Mkdir(path, 0755) 145 | if err != nil && os.IsExist(err) { 146 | err = nil 147 | } 148 | return err 149 | } 150 | 151 | func readdir(path string) dir { 152 | f, err := os.Open(path) 153 | if err != nil { 154 | if os.IsNotExist(err) { 155 | err = nil 156 | } 157 | } 158 | return dir{path: path, file: f, err: err} 159 | } 160 | 161 | func rmdir(path string) error { 162 | err := os.RemoveAll(path) 163 | if os.IsNotExist(err) { 164 | err = nil 165 | } 166 | return err 167 | } 168 | 169 | func unlink(path string) error { 170 | err := os.Remove(path) 171 | if os.IsNotExist(err) { 172 | err = nil 173 | } 174 | return err 175 | } 176 | 177 | func writeFile(path string, write func(*os.File) error) error { 178 | f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) 179 | if err != nil { 180 | return err 181 | } 182 | defer f.Close() 183 | return write(f) 184 | } 185 | -------------------------------------------------------------------------------- /tier_test.go: -------------------------------------------------------------------------------- 1 | package feature_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/segmentio/feature" 10 | ) 11 | 12 | func TestTier(t *testing.T) { 13 | tests := []struct { 14 | scenario string 15 | function func(*testing.T, *feature.Tier) 16 | }{ 17 | { 18 | scenario: "newly created tiers have no collections", 19 | function: testTierEmpty, 20 | }, 21 | 22 | { 23 | scenario: "opening a collection which does not exist returns an error", 24 | function: testTierOpenCollectionNotExist, 25 | }, 26 | 27 | { 28 | scenario: "collections created are exposed when listing collections", 29 | function: testTierCreateCollectionAndList, 30 | }, 31 | 32 | { 33 | scenario: "collections deleted are not exposed when listing collections", 34 | function: testTierDeleteCollectionAndList, 35 | }, 36 | 37 | { 38 | scenario: "ids added to a collection are exposed when listing ids", 39 | function: testTierCollectionAddAndList, 40 | }, 41 | } 42 | 43 | for _, test := range tests { 44 | t.Run(test.scenario, func(t *testing.T) { 45 | tmp, err := ioutil.TempDir("", "feature") 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | defer os.RemoveAll(tmp) 50 | path := feature.MountPoint(tmp) 51 | 52 | tier, err := path.CreateTier("standard", "1") 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | defer tier.Close() 57 | test.function(t, tier) 58 | }) 59 | } 60 | } 61 | 62 | func testTierEmpty(t *testing.T, tier *feature.Tier) { 63 | expectCollections(t, tier, []string{}) 64 | } 65 | 66 | func testTierOpenCollectionNotExist(t *testing.T, tier *feature.Tier) { 67 | _, err := tier.OpenCollection("whatever") 68 | if err == nil || !os.IsNotExist(err) { 69 | t.Error("unexpected error:", err) 70 | } 71 | } 72 | 73 | func testTierCreateCollectionAndList(t *testing.T, tier *feature.Tier) { 74 | c1 := createCollection(t, tier, "collection-1") 75 | c2 := createCollection(t, tier, "collection-2") 76 | c3 := createCollection(t, tier, "collection-3") 77 | 78 | defer c1.Close() 79 | defer c2.Close() 80 | defer c3.Close() 81 | 82 | expectCollections(t, tier, []string{ 83 | "collection-1", 84 | "collection-2", 85 | "collection-3", 86 | }) 87 | } 88 | 89 | func testTierDeleteCollectionAndList(t *testing.T, tier *feature.Tier) { 90 | c1 := createCollection(t, tier, "collection-1") 91 | c2 := createCollection(t, tier, "collection-2") 92 | c3 := createCollection(t, tier, "collection-3") 93 | 94 | defer c1.Close() 95 | defer c2.Close() 96 | defer c3.Close() 97 | 98 | deleteCollection(t, tier, "collection-2") 99 | 100 | expectCollections(t, tier, []string{ 101 | "collection-1", 102 | "collection-3", 103 | }) 104 | } 105 | 106 | func testTierCollectionAddAndList(t *testing.T, tier *feature.Tier) { 107 | col := createCollection(t, tier, "collection") 108 | defer col.Close() 109 | 110 | populateCollection(t, col, []string{ 111 | "id-1", 112 | "id-2", 113 | "id-3", 114 | }) 115 | 116 | expectIDs(t, tier, "collection", []string{ 117 | "id-1", 118 | "id-2", 119 | "id-3", 120 | }) 121 | } 122 | 123 | func createCollection(t testing.TB, tier *feature.Tier, collection string) *feature.Collection { 124 | t.Helper() 125 | 126 | c, err := tier.CreateCollection(collection) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | return c 132 | } 133 | 134 | func deleteCollection(t testing.TB, tier *feature.Tier, collection string) { 135 | t.Helper() 136 | 137 | if err := tier.DeleteCollection(collection); err != nil { 138 | t.Error(err) 139 | } 140 | } 141 | 142 | func expectCollections(t testing.TB, tier *feature.Tier, collections []string) { 143 | t.Helper() 144 | found := readAll(t, tier.Collections()) 145 | 146 | if !reflect.DeepEqual(found, collections) { 147 | t.Error("collections mismatch") 148 | t.Logf("want: %q", collections) 149 | t.Logf("got: %q", found) 150 | } 151 | } 152 | 153 | func expectIDs(t testing.TB, tier *feature.Tier, collection string, ids []string) { 154 | t.Helper() 155 | found := readAll(t, tier.IDs(collection)) 156 | 157 | if !reflect.DeepEqual(found, ids) { 158 | t.Error("ids mismatch") 159 | t.Logf("want: %q", ids) 160 | t.Logf("got: %q", found) 161 | } 162 | } 163 | 164 | func populateCollection(t testing.TB, col *feature.Collection, ids []string) { 165 | t.Helper() 166 | 167 | for _, id := range ids { 168 | col.Add(id) 169 | } 170 | 171 | if err := col.Sync(); err != nil { 172 | t.Error(err) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /gate_test.go: -------------------------------------------------------------------------------- 1 | package feature_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/segmentio/feature" 11 | ) 12 | 13 | func TestGate(t *testing.T) { 14 | tests := []struct { 15 | scenario string 16 | function func(*testing.T, feature.MountPoint, *feature.Tier) 17 | }{ 18 | { 19 | scenario: "enabled gates are exposed when listing gates for a tier", 20 | function: testTierGateEnabled, 21 | }, 22 | 23 | { 24 | scenario: "disabled gates are not exposed when listing gates for a tier", 25 | function: testTierGateDisabled, 26 | }, 27 | 28 | { 29 | scenario: "deleted gates are not exposed when listing gates for a tier", 30 | function: testTierGateDelete, 31 | }, 32 | } 33 | 34 | for _, test := range tests { 35 | t.Run(test.scenario, func(t *testing.T) { 36 | tmp, err := ioutil.TempDir("", "feature") 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | defer os.RemoveAll(tmp) 41 | path := feature.MountPoint(tmp) 42 | 43 | tier, err := path.CreateTier("standard", "1") 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | defer tier.Close() 48 | 49 | test.function(t, path, tier) 50 | }) 51 | } 52 | } 53 | 54 | func testTierGateEnabled(t *testing.T, path feature.MountPoint, tier *feature.Tier) { 55 | col := createCollection(t, tier, "collection") 56 | defer col.Close() 57 | 58 | populateCollection(t, col, []string{ 59 | "id-1", 60 | "id-2", 61 | "id-3", 62 | }) 63 | 64 | createGate(t, tier, "family-A", "gate-1", "collection", 1234) 65 | createGate(t, tier, "family-A", "gate-2", "collection", 2345) 66 | 67 | enableGate(t, tier, "family-A", "gate-1", "collection", 1.0, false) 68 | enableGate(t, tier, "family-A", "gate-2", "collection", 1.0, false) 69 | gates := map[string][]string{ 70 | "family-A": {"gate-1", "gate-2"}, 71 | } 72 | 73 | expectGatesEnabled(t, tier, "collection", "id-1", gates) 74 | expectGatesEnabled(t, tier, "collection", "id-2", gates) 75 | expectGatesEnabled(t, tier, "collection", "id-3", gates) 76 | } 77 | 78 | func testTierGateDisabled(t *testing.T, path feature.MountPoint, tier *feature.Tier) { 79 | col := createCollection(t, tier, "collection") 80 | defer col.Close() 81 | 82 | populateCollection(t, col, []string{ 83 | "id-1", 84 | "id-2", 85 | "id-3", 86 | }) 87 | 88 | createGate(t, tier, "family-A", "gate-1", "collection", 1234) 89 | createGate(t, tier, "family-A", "gate-2", "collection", 2345) 90 | 91 | enableGate(t, tier, "family-A", "gate-1", "collection", 0.0, false) 92 | enableGate(t, tier, "family-A", "gate-2", "collection", 0.0, false) 93 | gates := map[string][]string{} 94 | 95 | expectGatesEnabled(t, tier, "collection", "id-1", gates) 96 | expectGatesEnabled(t, tier, "collection", "id-2", gates) 97 | expectGatesEnabled(t, tier, "collection", "id-3", gates) 98 | } 99 | 100 | func testTierGateDelete(t *testing.T, path feature.MountPoint, tier *feature.Tier) { 101 | col := createCollection(t, tier, "collection") 102 | defer col.Close() 103 | 104 | populateCollection(t, col, []string{ 105 | "id-1", 106 | "id-2", 107 | "id-3", 108 | }) 109 | 110 | createGate(t, tier, "family-A", "gate-1", "collection", 1234) 111 | createGate(t, tier, "family-A", "gate-2", "collection", 2345) 112 | 113 | enableGate(t, tier, "family-A", "gate-1", "collection", 1.0, false) 114 | enableGate(t, tier, "family-A", "gate-2", "collection", 0.0, false) 115 | gates := map[string][]string{ 116 | "family-A": {"gate-1"}, 117 | } 118 | 119 | expectGatesEnabled(t, tier, "collection", "id-1", gates) 120 | expectGatesEnabled(t, tier, "collection", "id-2", gates) 121 | expectGatesEnabled(t, tier, "collection", "id-3", gates) 122 | } 123 | 124 | func createGate(t testing.TB, tier *feature.Tier, family, gate, collection string, salt uint32) { 125 | t.Helper() 126 | 127 | if err := tier.CreateGate(family, gate, collection, salt); err != nil { 128 | t.Error("unexpected error creating gate:", err) 129 | } 130 | } 131 | 132 | func enableGate(t testing.TB, tier *feature.Tier, family, gate, collection string, volume float64, open bool) { 133 | t.Helper() 134 | 135 | if err := tier.EnableGate(family, gate, collection, volume, open); err != nil { 136 | t.Error("unexpected error enabling gate:", err) 137 | } 138 | } 139 | 140 | func expectGatesEnabled(t testing.TB, tier *feature.Tier, collection, id string, gates map[string][]string) { 141 | t.Helper() 142 | 143 | it := tier.GatesEnabled(collection, id) 144 | defer it.Close() 145 | 146 | found := make(map[string][]string, len(gates)) 147 | for it.Next() { 148 | found[it.Family()] = append(found[it.Family()], it.Gate()) 149 | } 150 | 151 | for _, gates := range found { 152 | sort.Strings(gates) 153 | } 154 | 155 | if !reflect.DeepEqual(gates, found) { 156 | t.Error("gates mismatch") 157 | t.Logf("want: %+v", gates) 158 | t.Logf("got: %+v", found) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /fs_test.go: -------------------------------------------------------------------------------- 1 | package feature_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "sort" 8 | "testing" 9 | 10 | "github.com/segmentio/feature" 11 | ) 12 | 13 | func TestMountPoint(t *testing.T) { 14 | tests := []struct { 15 | scenario string 16 | function func(*testing.T, feature.MountPoint) 17 | }{ 18 | { 19 | scenario: "opening a tier which does not exist returns an error", 20 | function: testMountPointOpenTierNotExist, 21 | }, 22 | 23 | { 24 | scenario: "tiers created are exposed when listing groups and tiers", 25 | function: testMountPointCreateTierAndList, 26 | }, 27 | 28 | { 29 | scenario: "tiers deleted are not exposed anymore when listing groups and tiers", 30 | function: testMountPointDeleteTierAndList, 31 | }, 32 | 33 | { 34 | scenario: "deleting a tier which does not exist does nothing", 35 | function: testMountPointDeleteTierNotExist, 36 | }, 37 | 38 | { 39 | scenario: "deleting a group which does not exist does nothing", 40 | function: testMountPointDeleteGroupNotExist, 41 | }, 42 | } 43 | 44 | for _, test := range tests { 45 | t.Run(test.scenario, func(t *testing.T) { 46 | tmp, err := ioutil.TempDir("", "feature") 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | defer os.RemoveAll(tmp) 51 | p, err := feature.Mount(tmp) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | test.function(t, p) 56 | }) 57 | } 58 | } 59 | 60 | func testMountPointOpenTierNotExist(t *testing.T, path feature.MountPoint) { 61 | _, err := path.OpenTier("hello", "world") 62 | if err == nil || !os.IsNotExist(err) { 63 | t.Error("unexpected error:", err) 64 | } 65 | } 66 | 67 | func testMountPointCreateTierAndList(t *testing.T, path feature.MountPoint) { 68 | t1 := createTier(t, path, "group-A", "name-1") 69 | t2 := createTier(t, path, "group-A", "name-2") 70 | t3 := createTier(t, path, "group-B", "name-3") 71 | 72 | defer t1.Close() 73 | defer t2.Close() 74 | defer t3.Close() 75 | 76 | expectGroups(t, path, []string{ 77 | "group-A", 78 | "group-B", 79 | }) 80 | 81 | expectTiers(t, path, "group-A", []string{ 82 | "name-1", 83 | "name-2", 84 | }) 85 | 86 | expectTiers(t, path, "group-B", []string{ 87 | "name-3", 88 | }) 89 | } 90 | 91 | func testMountPointDeleteTierAndList(t *testing.T, path feature.MountPoint) { 92 | t1 := createTier(t, path, "group-A", "name-1") 93 | t2 := createTier(t, path, "group-A", "name-2") 94 | t3 := createTier(t, path, "group-B", "name-3") 95 | 96 | defer t1.Close() 97 | defer t2.Close() 98 | defer t3.Close() 99 | 100 | deleteTier(t, path, "group-A", "name-1") 101 | deleteTier(t, path, "group-B", "name-3") 102 | 103 | expectGroups(t, path, []string{ 104 | "group-A", 105 | "group-B", 106 | }) 107 | 108 | expectTiers(t, path, "group-A", []string{ 109 | "name-2", 110 | }) 111 | 112 | expectTiers(t, path, "group-B", []string{}) 113 | } 114 | 115 | func testMountPointDeleteTierNotExist(t *testing.T, path feature.MountPoint) { 116 | deleteTier(t, path, "group-A", "name-1") 117 | } 118 | 119 | func testMountPointDeleteGroupNotExist(t *testing.T, path feature.MountPoint) { 120 | deleteGroup(t, path, "group-A") 121 | } 122 | 123 | func createTier(t testing.TB, path feature.MountPoint, group, name string) *feature.Tier { 124 | t.Helper() 125 | 126 | g, err := path.CreateTier(group, name) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | return g 132 | } 133 | 134 | func deleteGroup(t testing.TB, path feature.MountPoint, group string) { 135 | t.Helper() 136 | 137 | if err := path.DeleteGroup(group); err != nil { 138 | t.Error(err) 139 | } 140 | } 141 | 142 | func deleteTier(t testing.TB, path feature.MountPoint, group, name string) { 143 | t.Helper() 144 | 145 | if err := path.DeleteTier(group, name); err != nil { 146 | t.Error(err) 147 | } 148 | } 149 | 150 | func expectGroups(t testing.TB, path feature.MountPoint, groups []string) { 151 | t.Helper() 152 | found := readAll(t, path.Groups()) 153 | 154 | if !reflect.DeepEqual(found, groups) { 155 | t.Error("groups mismatch") 156 | t.Logf("want: %q", groups) 157 | t.Logf("got: %q", found) 158 | } 159 | } 160 | 161 | func expectTiers(t testing.TB, path feature.MountPoint, group string, tiers []string) { 162 | t.Helper() 163 | found := readAll(t, path.Tiers(group)) 164 | 165 | if !reflect.DeepEqual(found, tiers) { 166 | t.Error("tiers mismatch") 167 | t.Logf("want: %q", tiers) 168 | t.Logf("got: %q", found) 169 | } 170 | 171 | for _, name := range tiers { 172 | g, err := path.OpenTier(group, name) 173 | if err != nil { 174 | t.Error(err) 175 | continue 176 | } 177 | if g.Group() != group { 178 | t.Errorf("tier group mismatch, want %q but got %q", group, g.Group()) 179 | } 180 | if g.Name() != name { 181 | t.Errorf("tier name mismatch, want %q but got %q", name, g.Name()) 182 | } 183 | } 184 | } 185 | 186 | func readAll(t testing.TB, it feature.Iter) []string { 187 | values := []string{} 188 | if err := feature.Scan(it, func(v string) error { 189 | values = append(values, v) 190 | return nil 191 | }); err != nil { 192 | t.Fatal(err) 193 | } 194 | sort.Strings(values) 195 | return values 196 | } 197 | -------------------------------------------------------------------------------- /tier.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | ) 9 | 10 | type GroupIter struct{ dir } 11 | 12 | func (it *GroupIter) Close() error { return it.close() } 13 | 14 | func (it *GroupIter) Next() bool { return it.next() } 15 | 16 | func (it *GroupIter) Name() string { return it.name() } 17 | 18 | func (it *GroupIter) Tiers() *TierIter { return &TierIter{it.read()} } 19 | 20 | type TierIter struct{ dir } 21 | 22 | func (it *TierIter) Close() error { return it.close() } 23 | 24 | func (it *TierIter) Next() bool { return it.next() } 25 | 26 | func (it *TierIter) Name() string { return it.name() } 27 | 28 | type Tier struct { 29 | path MountPoint 30 | group string 31 | name string 32 | } 33 | 34 | func (tier *Tier) Close() error { 35 | return nil 36 | } 37 | 38 | func (tier *Tier) String() string { 39 | return "/tiers/" + tier.group + "/" + tier.name 40 | } 41 | 42 | func (tier *Tier) Group() string { 43 | return tier.group 44 | } 45 | 46 | func (tier *Tier) Name() string { 47 | return tier.name 48 | } 49 | 50 | func (tier *Tier) CreateCollection(collection string) (*Collection, error) { 51 | if err := mkdir(tier.pathTo("collections")); err != nil { 52 | return nil, err 53 | } 54 | f, err := os.OpenFile(tier.collectionPath(collection), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) 55 | if err != nil { 56 | return nil, fmt.Errorf("creating collection %q: %w", collection, err) 57 | } 58 | return &Collection{file: f}, nil 59 | } 60 | 61 | func (tier *Tier) OpenCollection(collection string) (*Collection, error) { 62 | f, err := os.OpenFile(tier.collectionPath(collection), os.O_APPEND|os.O_WRONLY, 0644) 63 | if err != nil { 64 | if os.IsNotExist(err) { 65 | // Make a special case so the caller can use os.IsNotExist to test 66 | // the error. 67 | return nil, err 68 | } 69 | return nil, fmt.Errorf("opening collection %q: %w", collection, err) 70 | } 71 | return &Collection{file: f}, nil 72 | } 73 | 74 | func (tier *Tier) DeleteCollection(collection string) error { 75 | return unlink(tier.collectionPath(collection)) 76 | } 77 | 78 | func (tier *Tier) Collections() *CollectionIter { 79 | return &CollectionIter{readdir(tier.pathTo("collections"))} 80 | } 81 | 82 | func (tier *Tier) IDs(collection string) *IDIter { 83 | return &IDIter{readfile(tier.collectionPath(collection))} 84 | } 85 | 86 | func (tier *Tier) Families() *FamilyIter { 87 | return &FamilyIter{readdir(tier.pathTo("gates"))} 88 | } 89 | 90 | func (tier *Tier) Gates(family string) *GateIter { 91 | return &GateIter{readdir(tier.familyPath(family))} 92 | } 93 | 94 | func (tier *Tier) GatesCreated(family, gate string) *GateCreatedIter { 95 | return &GateCreatedIter{readdir(tier.gatePath(family, gate))} 96 | } 97 | 98 | func (tier *Tier) GatesEnabled(collection, id string) *GateEnabledIter { 99 | id, families := tier.gates(collection, id) 100 | return &GateEnabledIter{ 101 | path: tier.path, 102 | families: families, 103 | collection: collection, 104 | id: id, 105 | } 106 | } 107 | 108 | func (tier *Tier) GatesDisabled(collection, id string) *GateDisabledIter { 109 | id, families := tier.gates(collection, id) 110 | return &GateDisabledIter{ 111 | path: tier.path, 112 | families: families, 113 | collection: collection, 114 | id: id, 115 | } 116 | } 117 | 118 | func (tier *Tier) gates(collection, id string) (string, dir) { 119 | it := tier.IDs(collection) 120 | defer it.Close() 121 | 122 | found := false 123 | for it.Next() { 124 | if it.Name() == id { 125 | found = true 126 | break 127 | } 128 | } 129 | 130 | if !found { 131 | id = "" 132 | } 133 | 134 | return id, readdir(tier.pathTo("gates")) 135 | } 136 | 137 | func (tier *Tier) CreateGate(family, name, collection string, salt uint32) error { 138 | if err := mkdir(tier.pathTo("gates")); err != nil { 139 | return err 140 | } 141 | if err := mkdir(tier.familyPath(family)); err != nil { 142 | return err 143 | } 144 | if err := mkdir(tier.gatePath(family, name)); err != nil { 145 | return err 146 | } 147 | return writeGate(tier.gateCollectionPath(family, name, collection), gate{ 148 | salt: strconv.FormatUint(uint64(salt), 10), 149 | }) 150 | } 151 | 152 | func (tier *Tier) EnableGate(family, name, collection string, volume float64, open bool) error { 153 | path := tier.gateCollectionPath(family, name, collection) 154 | g, err := readGate(path) 155 | if err != nil { 156 | return err 157 | } 158 | g.open, g.volume = open, volume 159 | return writeGate(path, g) 160 | } 161 | 162 | func (tier *Tier) ReadGate(family, name, collection string) (open bool, salt string, volume float64, err error) { 163 | g, err := readGate(tier.gateCollectionPath(family, name, collection)) 164 | return g.open, g.salt, g.volume, err 165 | } 166 | 167 | func (tier *Tier) DeleteGate(family, name, collection string) error { 168 | return rmdir(tier.gateCollectionPath(family, name, collection)) 169 | } 170 | 171 | func (tier *Tier) familyPath(family string) string { 172 | return filepath.Join(string(tier.path), tier.group, tier.name, "gates", family) 173 | } 174 | 175 | func (tier *Tier) gatePath(family, name string) string { 176 | return filepath.Join(string(tier.path), tier.group, tier.name, "gates", family, name) 177 | } 178 | 179 | func (tier *Tier) collectionPath(collection string) string { 180 | return filepath.Join(string(tier.path), tier.group, tier.name, "collections", collection) 181 | } 182 | 183 | func (tier *Tier) gateCollectionPath(family, name, collection string) string { 184 | return filepath.Join(string(tier.path), tier.group, tier.name, "gates", family, name, collection) 185 | } 186 | 187 | func (tier *Tier) pathTo(path string) string { 188 | return filepath.Join(string(tier.path), tier.group, tier.name, path) 189 | } 190 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package feature_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "math/rand" 6 | "os" 7 | "reflect" 8 | "strconv" 9 | "testing" 10 | 11 | "github.com/segmentio/feature" 12 | ) 13 | 14 | func TestCache(t *testing.T) { 15 | tmp, err := ioutil.TempDir("", "feature") 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | defer os.RemoveAll(tmp) 20 | path := feature.MountPoint(tmp) 21 | 22 | tier1 := createTier(t, path, "standard", "1") 23 | tier2 := createTier(t, path, "standard", "2") 24 | tier3 := createTier(t, path, "standard", "3") 25 | 26 | defer tier1.Close() 27 | defer tier2.Close() 28 | defer tier3.Close() 29 | 30 | col1 := createCollection(t, tier1, "workspaces") 31 | col2 := createCollection(t, tier2, "workspaces") 32 | col3 := createCollection(t, tier3, "workspaces") 33 | 34 | defer col1.Close() 35 | defer col2.Close() 36 | defer col3.Close() 37 | 38 | populateCollection(t, col1, []string{"id-1"}) 39 | populateCollection(t, col2, []string{"id-2", "id-3"}) 40 | populateCollection(t, col3, []string{"id-4", "id-5", "id-6"}) 41 | 42 | createGate(t, tier1, "family-A", "gate-1", "workspaces", 1234) 43 | createGate(t, tier1, "family-A", "gate-2", "workspaces", 2345) 44 | createGate(t, tier1, "family-B", "gate-3", "workspaces", 3456) 45 | createGate(t, tier2, "family-B", "gate-3", "workspaces", 7890) 46 | 47 | enableGate(t, tier1, "family-A", "gate-1", "workspaces", 1.0, false) 48 | enableGate(t, tier1, "family-A", "gate-2", "workspaces", 1.0, false) 49 | enableGate(t, tier1, "family-B", "gate-3", "workspaces", 0.0, false) 50 | enableGate(t, tier2, "family-B", "gate-3", "workspaces", 1.0, true) 51 | 52 | cache, err := path.Load() 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | defer cache.Close() 57 | 58 | expectGateOpened(t, cache, "family-A", "gate-1", "workspaces", "id-1") 59 | expectGateClosed(t, cache, "family-A", "gate-1", "workspaces", "id-2") 60 | expectGateLookup(t, cache, "family-A", "workspaces", "id-1", []string{"gate-1", "gate-2"}) 61 | 62 | // id-1 is in tier 1 where gate-3 is explicitly disabled, the gate must only 63 | // be enabled for ids of tier 2 and 3, and other random ids which appear in 64 | // none of the tiers because the gate is in an open state in tier 2. 65 | expectGateClosed(t, cache, "family-B", "gate-3", "workspaces", "id-1") 66 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "id-2") 67 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "id-3") 68 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "id-4") 69 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "id-5") 70 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "id-6") 71 | expectGateOpened(t, cache, "family-B", "gate-3", "workspaces", "whatever") 72 | } 73 | 74 | func expectGateOpened(t testing.TB, cache *feature.Cache, family, gate, collection, id string) { 75 | t.Helper() 76 | expectGateIsEnabled(t, cache, family, gate, collection, id, true) 77 | } 78 | 79 | func expectGateClosed(t testing.TB, cache *feature.Cache, family, gate, collection, id string) { 80 | t.Helper() 81 | expectGateIsEnabled(t, cache, family, gate, collection, id, false) 82 | } 83 | 84 | func expectGateIsEnabled(t testing.TB, cache *feature.Cache, family, gate, collection, id string, open bool) { 85 | t.Helper() 86 | 87 | if cache.GateOpen(family, gate, collection, id) != open { 88 | t.Error("gate state mismatch") 89 | t.Logf("want: %t", open) 90 | t.Logf("got: %t", !open) 91 | } 92 | } 93 | 94 | func expectGateLookup(t testing.TB, cache *feature.Cache, family, collection, id string, gates []string) { 95 | t.Helper() 96 | 97 | if found := cache.LookupGates(family, collection, id); !reflect.DeepEqual(found, gates) { 98 | t.Error("gates mismatch") 99 | t.Logf("want: %q", gates) 100 | t.Logf("got: %q", found) 101 | } 102 | } 103 | 104 | func BenchmarkCache(b *testing.B) { 105 | tmp, err := ioutil.TempDir("", "feature") 106 | if err != nil { 107 | b.Fatal(err) 108 | } 109 | defer os.RemoveAll(tmp) 110 | path := feature.MountPoint(tmp) 111 | 112 | tier1 := createTier(b, path, "standard", "1") 113 | tier2 := createTier(b, path, "standard", "2") 114 | 115 | defer tier1.Close() 116 | defer tier2.Close() 117 | 118 | col1 := createCollection(b, tier1, "workspaces") 119 | col2 := createCollection(b, tier2, "workspaces") 120 | 121 | defer col1.Close() 122 | defer col2.Close() 123 | 124 | createGate(b, tier1, "family-A", "gate-1", "workspaces", 1234) 125 | createGate(b, tier1, "family-A", "gate-2", "workspaces", 2345) 126 | 127 | createGate(b, tier2, "family-A", "gate-1", "workspaces", 1234) 128 | createGate(b, tier2, "family-A", "gate-2", "workspaces", 2345) 129 | 130 | enableGate(b, tier1, "family-A", "gate-1", "workspaces", 1.0, false) 131 | enableGate(b, tier1, "family-A", "gate-2", "workspaces", 1.0, false) 132 | 133 | enableGate(b, tier2, "family-A", "gate-1", "workspaces", 0.0, false) 134 | enableGate(b, tier2, "family-A", "gate-2", "workspaces", 0.0, false) 135 | 136 | const N = 10e3 137 | prng := rand.New(rand.NewSource(0)) 138 | ids := make([]string, N) 139 | for i := range ids { 140 | ids[i] = strconv.FormatInt(prng.Int63(), 16) 141 | } 142 | 143 | populateCollection(b, col1, ids[:N/2]) 144 | populateCollection(b, col2, ids[N/2:]) 145 | 146 | cache, err := path.Load() 147 | if err != nil { 148 | b.Fatal(err) 149 | } 150 | defer cache.Close() 151 | 152 | b.Run("enabled", func(b *testing.B) { 153 | for i := 0; i < b.N; i++ { 154 | if !cache.GateOpen("family-A", "gate-2", "workspaces", ids[i%(N/2)]) { 155 | b.Fatal("gate not enabled") 156 | } 157 | } 158 | }) 159 | 160 | b.Run("disabled", func(b *testing.B) { 161 | for i := 0; i < b.N; i++ { 162 | if cache.GateOpen("family-A", "gate-2", "workspaces", ids[(N/2)+i%(N/2)]) { 163 | b.Fatal("gate not disabled") 164 | } 165 | } 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /gate.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "hash" 7 | "hash/fnv" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strconv" 12 | "sync" 13 | "unicode" 14 | ) 15 | 16 | type FamilyIter struct{ dir } 17 | 18 | func (it *FamilyIter) Close() error { return it.close() } 19 | 20 | func (it *FamilyIter) Next() bool { return it.next() } 21 | 22 | func (it *FamilyIter) Name() string { return it.name() } 23 | 24 | func (it *FamilyIter) Gates() *GateIter { return &GateIter{it.read()} } 25 | 26 | type GateIter struct{ dir } 27 | 28 | func (it *GateIter) Close() error { return it.close() } 29 | 30 | func (it *GateIter) Next() bool { return it.next() } 31 | 32 | func (it *GateIter) Name() string { return it.name() } 33 | 34 | func (it *GateIter) Created() *GateCreatedIter { return &GateCreatedIter{it.read()} } 35 | 36 | type GateCreatedIter struct{ dir } 37 | 38 | func (it *GateCreatedIter) Close() error { return it.close() } 39 | 40 | func (it *GateCreatedIter) Next() bool { return it.next() } 41 | 42 | func (it *GateCreatedIter) Name() string { return it.name() } 43 | 44 | type GateEnabledIter struct { 45 | path MountPoint 46 | families dir 47 | gates dir 48 | collection string 49 | id string 50 | salt uint32 51 | err error 52 | hash *bufferedHash64 53 | } 54 | 55 | func (it *GateEnabledIter) Close() error { 56 | if it.hash != nil { 57 | releaseBufferedHash64(it.hash) 58 | it.hash = nil 59 | } 60 | err1 := it.families.close() 61 | err2 := it.gates.close() 62 | if err2 != nil { 63 | return err2 64 | } 65 | if err1 != nil { 66 | return err1 67 | } 68 | return it.err 69 | } 70 | 71 | func (it *GateEnabledIter) Next() bool { 72 | if it.hash == nil { 73 | it.hash = acquireBufferedHash64() 74 | } 75 | 76 | for { 77 | if it.gates.opened() { 78 | for it.gates.next() { 79 | g, err := readGate(filepath.Join(it.gates.path, it.gates.name(), it.collection)) 80 | if err != nil { 81 | if os.IsNotExist(err) { 82 | continue 83 | } 84 | it.err = err 85 | it.Close() 86 | return false 87 | } 88 | 89 | if it.id == "" { 90 | if g.open { 91 | return true 92 | } 93 | } else if openGate(it.id, g.salt, g.volume, it.hash) { 94 | return true 95 | } 96 | } 97 | 98 | if it.gates.close() != nil { 99 | return false 100 | } 101 | } 102 | 103 | if !it.families.next() { 104 | return false 105 | } 106 | 107 | it.gates = it.families.read() 108 | } 109 | } 110 | 111 | func (it *GateEnabledIter) Family() string { 112 | return it.families.name() 113 | } 114 | 115 | func (it *GateEnabledIter) Gate() string { 116 | return it.gates.name() 117 | } 118 | 119 | func (it *GateEnabledIter) Name() string { 120 | return it.Family() + "/" + it.Gate() 121 | } 122 | 123 | type GateDisabledIter struct { 124 | path MountPoint 125 | families dir 126 | gates dir 127 | collection string 128 | id string 129 | salt uint32 130 | err error 131 | hash *bufferedHash64 132 | } 133 | 134 | func (it *GateDisabledIter) Close() error { 135 | if it.hash != nil { 136 | releaseBufferedHash64(it.hash) 137 | it.hash = nil 138 | } 139 | err1 := it.families.close() 140 | err2 := it.gates.close() 141 | if err2 != nil { 142 | return err2 143 | } 144 | if err1 != nil { 145 | return err1 146 | } 147 | return it.err 148 | } 149 | 150 | func (it *GateDisabledIter) Next() bool { 151 | if it.id == "" { 152 | return false 153 | } 154 | 155 | if it.hash == nil { 156 | it.hash = acquireBufferedHash64() 157 | } 158 | 159 | for { 160 | if it.gates.opened() { 161 | for it.gates.next() { 162 | g, err := readGate(filepath.Join(it.gates.path, it.gates.name(), it.collection)) 163 | if err != nil { 164 | if os.IsNotExist(err) { 165 | continue 166 | } 167 | it.err = err 168 | it.Close() 169 | return false 170 | } 171 | 172 | if !openGate(it.id, g.salt, g.volume, it.hash) { 173 | return true 174 | } 175 | } 176 | 177 | if it.gates.close() != nil { 178 | return false 179 | } 180 | } 181 | 182 | if !it.families.next() { 183 | return false 184 | } 185 | 186 | it.gates = it.families.read() 187 | } 188 | } 189 | 190 | func (it *GateDisabledIter) Family() string { 191 | return it.families.name() 192 | } 193 | 194 | func (it *GateDisabledIter) Gate() string { 195 | return it.gates.name() 196 | } 197 | 198 | func (it *GateDisabledIter) Name() string { 199 | return it.Family() + "/" + it.Gate() 200 | } 201 | 202 | // openGate is an algorithm we used historically in internal feature gating 203 | // systems. We adopted it here for interoperability purposes. 204 | func openGate(id, salt string, volume float64, h *bufferedHash64) bool { 205 | if volume <= 0 { 206 | return false 207 | } 208 | 209 | if volume >= 1 { 210 | return true 211 | } 212 | 213 | h.buffer.WriteString(id) 214 | h.buffer.WriteString(salt) 215 | h.buffer.WriteTo(h.hash) 216 | 217 | return (float64(h.hash.Sum64()%100) + 1) <= (100 * volume) 218 | } 219 | 220 | type gate struct { 221 | open bool 222 | salt string 223 | volume float64 224 | } 225 | 226 | func readGate(path string) (gate, error) { 227 | var g gate 228 | 229 | f, err := os.Open(path) 230 | if err != nil { 231 | return g, err 232 | } 233 | defer f.Close() 234 | 235 | b, err := mmap(f) 236 | if err != nil { 237 | return g, &os.PathError{Op: "mmap", Path: path, Err: err} 238 | } 239 | defer munmap(b) 240 | 241 | forEachLine(b, func(i, n int) { 242 | if err != nil { 243 | return 244 | } 245 | k, v := splitKeyValue(bytes.TrimSpace(b[i : i+n])) 246 | switch string(k) { 247 | case "open": 248 | g.open, err = strconv.ParseBool(string(v)) 249 | case "salt": 250 | g.salt = string(v) 251 | case "volume": 252 | g.volume, err = strconv.ParseFloat(string(v), 64) 253 | } 254 | }) 255 | 256 | if err != nil { 257 | err = &os.PathError{Op: "read", Path: path, Err: err} 258 | } 259 | 260 | return g, err 261 | } 262 | 263 | func writeGate(path string, gate gate) error { 264 | b := new(bytes.Buffer) 265 | 266 | for _, e := range [...]struct { 267 | key string 268 | value interface{} 269 | }{ 270 | {key: "open", value: gate.open}, 271 | {key: "salt", value: gate.salt}, 272 | {key: "volume", value: gate.volume}, 273 | } { 274 | if err := writeKeyValue(b, e.key, e.value); err != nil { 275 | return err 276 | } 277 | } 278 | 279 | return writeFile(path, func(f *os.File) error { 280 | _, err := b.WriteTo(f) 281 | return err 282 | }) 283 | } 284 | 285 | func writeKeyValue(w io.Writer, key string, value interface{}) error { 286 | _, err := fmt.Fprintf(w, "%s\t%v\n", key, value) 287 | return err 288 | } 289 | 290 | func splitKeyValue(line []byte) ([]byte, []byte) { 291 | i := bytes.IndexFunc(line, unicode.IsSpace) 292 | if i < 0 { 293 | return bytes.TrimSpace(line), nil 294 | } 295 | return bytes.TrimSpace(line[:i]), bytes.TrimSpace(line[i:]) 296 | } 297 | 298 | var hashes sync.Pool // *bufferedHash64 299 | 300 | type bufferedHash64 struct { 301 | buffer bytes.Buffer 302 | hash hash.Hash64 303 | } 304 | 305 | func acquireBufferedHash64() *bufferedHash64 { 306 | h, _ := hashes.Get().(*bufferedHash64) 307 | if h == nil { 308 | h = &bufferedHash64{hash: fnv.New64a()} 309 | h.buffer.Grow(128) 310 | } else { 311 | h.buffer.Reset() 312 | h.hash.Reset() 313 | } 314 | return h 315 | } 316 | 317 | func releaseBufferedHash64(h *bufferedHash64) { 318 | hashes.Put(h) 319 | } 320 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "bytes" 5 | "container/list" 6 | "hash/maphash" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "sync" 11 | ) 12 | 13 | // Cache is an in-memory view of feature mount point on a file system. 14 | // 15 | // The cache is optimized for fast lookups of gates, and fast test of gate 16 | // open states for an id. The cache is also immutable, and therefore safe to 17 | // use concurrently from multiple goroutines. 18 | // 19 | // The cache is designed to minimize the memory footprint. The underlying files 20 | // containing the id collections are memory mapped so multiple programs are able 21 | // to share the memory pages. 22 | type Cache struct { 23 | cache lruCache 24 | mutex sync.RWMutex 25 | tiers []cachedTier 26 | } 27 | 28 | func (c *Cache) swap(x *Cache) *Cache { 29 | c.mutex.Lock() 30 | defer c.mutex.Unlock() 31 | c.tiers, x.tiers = x.tiers, c.tiers 32 | c.cache.clear() 33 | return x 34 | } 35 | 36 | // Close releases resources held by the cache. 37 | func (c *Cache) Close() error { 38 | c.mutex.Lock() 39 | defer c.mutex.Unlock() 40 | 41 | for i := range c.tiers { 42 | for _, c := range c.tiers[i].collections { 43 | c.unmap() 44 | } 45 | } 46 | 47 | c.tiers = nil 48 | c.cache.clear() 49 | return nil 50 | } 51 | 52 | // GateOpen returns true if a gate is opened for a given id. 53 | // 54 | // The method does not retain any of the strings passed as arguments. 55 | func (c *Cache) GateOpen(family, gate, collection, id string) bool { 56 | g := c.LookupGates(family, collection, id) 57 | i := sort.Search(len(g), func(i int) bool { 58 | return g[i] >= gate 59 | }) 60 | return i < len(g) && g[i] == gate 61 | } 62 | 63 | // LookupGates returns the list of open gates in a family for a given id. 64 | // 65 | // The method does not retain any of the strings passed as arguments. 66 | func (c *Cache) LookupGates(family, collection, id string) []string { 67 | key := lruCacheKey{ 68 | family: family, 69 | collection: collection, 70 | id: id, 71 | } 72 | 73 | if v := c.cache.lookup(key); v != nil && v.key == key { 74 | return v.gates 75 | } 76 | 77 | buf := family + collection + id 78 | key = lruCacheKey{ 79 | family: buf[:len(family)], 80 | collection: buf[len(family) : len(family)+len(collection)], 81 | id: buf[len(family)+len(collection):], 82 | } 83 | 84 | disabled := make(map[string]struct{}) 85 | gates := make([]string, 0, 8) 86 | defer func() { 87 | c.cache.insert(key, gates, 4096) 88 | }() 89 | 90 | h := acquireBufferedHash64() 91 | defer releaseBufferedHash64(h) 92 | 93 | c.mutex.RLock() 94 | defer c.mutex.RUnlock() 95 | 96 | for i := range c.tiers { 97 | t := &c.tiers[i] 98 | c := t.collections[collection] 99 | exists := c != nil && c.contains(id) 100 | 101 | for _, g := range t.gates[family] { 102 | if g.collection == collection { 103 | if exists { 104 | if openGate(id, g.salt, g.volume, h) { 105 | gates = append(gates, g.name) 106 | } else { 107 | disabled[g.name] = struct{}{} 108 | } 109 | } else if g.open { 110 | gates = append(gates, g.name) 111 | } 112 | } 113 | } 114 | } 115 | 116 | if len(gates) == 0 { 117 | gates = nil 118 | } else { 119 | sort.Strings(gates) 120 | gates = deduplicate(gates) 121 | gates = strip(gates, disabled) 122 | // Safe guard in case the program appends to the slice, it will force 123 | // the reallocation and copy. 124 | gates = gates[:len(gates):len(gates)] 125 | } 126 | 127 | return gates 128 | } 129 | 130 | func deduplicate(s []string) []string { 131 | n := 0 132 | 133 | for i := 1; i < len(s); i++ { 134 | if s[i] != s[n] { 135 | n++ 136 | s[n] = s[i] 137 | } 138 | } 139 | 140 | for i := n + 1; i < len(s); i++ { 141 | s[i] = "" 142 | } 143 | 144 | return s[:n+1] 145 | } 146 | 147 | func strip(s []string, disabled map[string]struct{}) []string { 148 | n := 0 149 | 150 | for _, x := range s { 151 | if _, skip := disabled[x]; !skip { 152 | s[n] = x 153 | n++ 154 | } 155 | } 156 | 157 | for i := n; i < len(s); i++ { 158 | s[i] = "" 159 | } 160 | 161 | return s[:n] 162 | } 163 | 164 | type cachedTier struct { 165 | group string 166 | name string 167 | collections map[string]*collection 168 | gates map[string][]cachedGate 169 | } 170 | 171 | type cachedGate struct { 172 | name string 173 | collection string 174 | salt string 175 | volume float64 176 | open bool 177 | } 178 | 179 | // The Load method loads the features at the mount point it is called on, 180 | // returning a Cache object exposing the state. 181 | // 182 | // The returned cache holds operating system resources and therefore must be 183 | // closed when the program does not need it anymore. 184 | func (path MountPoint) Load() (*Cache, error) { 185 | // Resolves symlinks first so we know that the underlying directory 186 | // structure will not change across reads from the file system when 187 | // loading the cache. 188 | p, err := filepath.EvalSymlinks(string(path)) 189 | if err != nil { 190 | return nil, err 191 | } 192 | path = MountPoint(p) 193 | // To minimize the memory footprint of the cache, strings are deduplicated 194 | // using this map, so we only retain only one copy of each string value. 195 | strings := stringCache{} 196 | 197 | tiers := make([]cachedTier, 0) 198 | 199 | if err := Scan(path.Groups(), func(group string) error { 200 | return Scan(path.Tiers(group), func(tier string) error { 201 | t, err := path.OpenTier(group, tier) 202 | if err != nil { 203 | return err 204 | } 205 | defer t.Close() 206 | 207 | c := cachedTier{ 208 | group: strings.load(group), 209 | name: strings.load(tier), 210 | collections: make(map[string]*collection), 211 | gates: make(map[string][]cachedGate), 212 | } 213 | 214 | if err := Scan(t.Families(), func(family string) error { 215 | return Scan(t.Gates(family), func(gate string) error { 216 | f := strings.load(family) 217 | d := readdir(t.gatePath(family, gate)) 218 | defer d.close() 219 | 220 | for d.next() { 221 | open, salt, volume, err := t.ReadGate(family, gate, d.name()) 222 | if err != nil { 223 | return err 224 | } 225 | c.gates[f] = append(c.gates[f], cachedGate{ 226 | name: strings.load(gate), 227 | collection: strings.load(d.name()), 228 | salt: salt, 229 | volume: volume, 230 | open: open, 231 | }) 232 | } 233 | 234 | return nil 235 | }) 236 | }); err != nil { 237 | return err 238 | } 239 | 240 | if err := Scan(t.Collections(), func(collection string) error { 241 | col, err := mmapCollection(t.collectionPath(collection)) 242 | if err != nil { 243 | return err 244 | } 245 | c.collections[strings.load(collection)] = col 246 | return nil 247 | }); err != nil { 248 | return err 249 | } 250 | 251 | tiers = append(tiers, c) 252 | return nil 253 | }) 254 | }); err != nil { 255 | return nil, err 256 | } 257 | 258 | for _, tier := range tiers { 259 | for _, gates := range tier.gates { 260 | sort.Slice(gates, func(i, j int) bool { 261 | return gates[i].name < gates[j].name 262 | }) 263 | } 264 | } 265 | 266 | return &Cache{tiers: tiers}, nil 267 | } 268 | 269 | type slice struct { 270 | offset uint32 271 | length uint32 272 | } 273 | 274 | type collection struct { 275 | memory []byte 276 | index []slice 277 | } 278 | 279 | func (col *collection) at(i int) []byte { 280 | slice := col.index[i] 281 | return col.memory[slice.offset : slice.offset+slice.length] 282 | } 283 | 284 | func (col *collection) contains(id string) bool { 285 | i := sort.Search(len(col.index), func(i int) bool { 286 | return string(col.at(i)) >= id 287 | }) 288 | return i < len(col.index) && string(col.at(i)) == id 289 | } 290 | 291 | func (col *collection) unmap() { 292 | munmap(col.memory) 293 | col.memory, col.index = nil, nil 294 | } 295 | 296 | func (col *collection) Len() int { return len(col.index) } 297 | func (col *collection) Less(i, j int) bool { return string(col.at(i)) < string(col.at(j)) } 298 | func (col *collection) Swap(i, j int) { col.index[i], col.index[j] = col.index[j], col.index[i] } 299 | 300 | func mmapCollection(path string) (*collection, error) { 301 | f, err := os.Open(path) 302 | if err != nil { 303 | return nil, err 304 | } 305 | defer f.Close() 306 | 307 | m, err := mmap(f) 308 | if err != nil { 309 | return nil, err 310 | } 311 | 312 | count := 0 313 | forEachLine(m, func(int, int) { count++ }) 314 | 315 | index := make([]slice, 0, count) 316 | forEachLine(m, func(off, len int) { 317 | index = append(index, slice{ 318 | offset: uint32(off), 319 | length: uint32(len), 320 | }) 321 | }) 322 | 323 | col := &collection{memory: m, index: index} 324 | if !sort.IsSorted(col) { 325 | sort.Sort(col) 326 | } 327 | return col, nil 328 | } 329 | 330 | func forEachLine(b []byte, do func(off, len int)) { 331 | for i := 0; i < len(b); { 332 | n := bytes.IndexByte(b[i:], '\n') 333 | if n < 0 { 334 | n = len(b) - i 335 | } 336 | do(i, n) 337 | i += n + 1 338 | } 339 | } 340 | 341 | type stringCache map[string]string 342 | 343 | func (c stringCache) load(s string) string { 344 | v, ok := c[s] 345 | if ok { 346 | return v 347 | } 348 | c[s] = s 349 | return s 350 | } 351 | 352 | var lruCacheSeed = maphash.MakeSeed() 353 | 354 | type lruCacheKey struct { 355 | family string 356 | collection string 357 | id string 358 | } 359 | 360 | func (k *lruCacheKey) hash(h *maphash.Hash) uint64 { 361 | h.WriteString(k.family) 362 | h.WriteString(k.collection) 363 | h.WriteString(k.id) 364 | return h.Sum64() 365 | } 366 | 367 | type lruCacheValue struct { 368 | key lruCacheKey 369 | gates []string 370 | } 371 | 372 | type lruCache struct { 373 | mutex sync.Mutex 374 | queue list.List 375 | cache map[uint64]*list.Element 376 | } 377 | 378 | func (c *lruCache) clear() { 379 | c.mutex.Lock() 380 | c.queue = list.List{} 381 | for key := range c.cache { 382 | delete(c.cache, key) 383 | } 384 | c.mutex.Unlock() 385 | } 386 | 387 | func (c *lruCache) insert(key lruCacheKey, gates []string, limit int) { 388 | m := maphash.Hash{} 389 | m.SetSeed(lruCacheSeed) 390 | h := key.hash(&m) 391 | 392 | v := &lruCacheValue{ 393 | key: key, 394 | gates: gates, 395 | } 396 | 397 | c.mutex.Lock() 398 | defer c.mutex.Unlock() 399 | 400 | if c.cache == nil { 401 | c.cache = make(map[uint64]*list.Element) 402 | } 403 | 404 | e := c.queue.PushBack(v) 405 | c.cache[h] = e 406 | 407 | for limit > 0 && len(c.cache) > limit { 408 | e := c.queue.Back() 409 | v := e.Value.(*lruCacheValue) 410 | c.queue.Remove(e) 411 | m.Reset() 412 | delete(c.cache, v.key.hash(&m)) 413 | } 414 | } 415 | 416 | func (c *lruCache) lookup(key lruCacheKey) *lruCacheValue { 417 | m := maphash.Hash{} 418 | m.SetSeed(lruCacheSeed) 419 | h := key.hash(&m) 420 | 421 | c.mutex.Lock() 422 | defer c.mutex.Unlock() 423 | 424 | e := c.cache[h] 425 | if e != nil { 426 | c.queue.MoveToFront(e) 427 | return e.Value.(*lruCacheValue) 428 | } 429 | 430 | return nil 431 | } 432 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feature ![build status](https://github.com/segmentio/feature/actions/workflows/test.yml/badge.svg) [![GoDoc](https://godoc.org/github.com/segmentio/feature?status.svg)](https://godoc.org/github.com/segmentio/feature) 2 | 3 | Feature gate database designed for simplicity and efficiency. 4 | 5 | ## Motivation 6 | 7 | Feature gates are an important part of controlling the risk associated with 8 | software releases, they bring safe guards and granular knobs over the exposure 9 | of data to new code paths. 10 | 11 | However, these promises can only be kept if programs can reliably access the 12 | feature gate data, and query the data set with high efficiency. Most feature 13 | gate systems rely on performing network calls to a foreign system, creating 14 | opportunities for cascading failures in distributed systems where feature gate 15 | checks are often performed on critical data paths. 16 | 17 | The `feature` package was designed to offer high availbility of the feature 18 | gates, and high query performance, allowing its use in large scale systems with 19 | many *nines* of uptime like those run by Segment. 20 | 21 | ### Reliability 22 | 23 | The feature database is represented by an immutable set of directories and 24 | files on a file system. The level of reliability offered by a set of files on 25 | disk exceeds by a wide margin what can be achieved with a daemon process serving 26 | the data over a network interface. Would the program updating the feature 27 | database be restarted or crashed, the files would remain available for consumers 28 | to read and query. The system is known to _fail static_: in the worst case 29 | scenario, nothing changes. 30 | 31 | ### Efficiency 32 | 33 | The feature database being immutable, it enables very efficient access to 34 | the data. Programs can implement simple and high performance caching layers 35 | because they do not need to manage cache expirations or transactional updates. 36 | The database files are mapped to read-only memory areas and therefore can be 37 | shared by all collocated processes, ensuring that a single copy of the data 38 | ever exists in memory. 39 | 40 | ## Data Models 41 | 42 | ### Collections 43 | 44 | Collections are lists of unique identifiers that programs can query the state 45 | of gates for; gates are either open or closed. The collections are arranged in 46 | groups and tiers. Each group may have multiple tiers, within each tier the 47 | collection files contain the list of identifiers, one by line. 48 | 49 | Here is an example of the on-disk representation of collections: 50 | ``` 51 | $ tree 52 | . 53 | └── standard 54 | ├── 1 55 | │   ├── collections 56 | │   │   ├── source 57 | │   │   ├── workspace 58 | │   │   └── write_key 59 | ... 60 | ``` 61 | _For the standard group, tier 1, there are three collections of source, workspace 62 | and write keys._ 63 | 64 | ``` 65 | $ cat ./standard/1/collections/source 66 | ACAtsprztv 67 | B458ru47n7 68 | CQRxBaQSt8 69 | EJw9i04Lsv 70 | IbQor7hHBU 71 | LZK0HYwDTH 72 | MKOxgJsedB 73 | OmNMfU6RbP 74 | Q5lmdTzq1Y 75 | SqNT0bDYl7 76 | ... 77 | ``` 78 | 79 | On-disk file structures with large number of directories and small files cause 80 | space usage amplification, leading to large amounts of wasted space. 81 | By analyzing the volume of data used to represent feature flags, we observed 82 | that most of the space was used by the collections of identifiers. Text files 83 | provide a compact representation of the identifiers, minimizing storage space 84 | waste caused by block size alignment, and offering a scalable model to grow 85 | the number of collections and identifiers in the system. 86 | 87 | ### Gates 88 | 89 | The second core data type are feature gates, which are grouped by family, name, 90 | and collections that they apply to. The gate family and name are represented as 91 | directories, and the gate data per collection are stored in text files of 92 | key/value pairs. 93 | 94 | Continuing on our previous example, here is a view of the way gates are laid out 95 | in the file system: 96 | ``` 97 | $ tree 98 | . 99 | └── standard 100 | ├── 1 101 | ... 102 | │   └── gates 103 | │   ├── access-management 104 | │   │   └── invite-flow-enabled 105 | │   │   └── workspace 106 | ... 107 | ``` 108 | _For the standard group, tier 1, gate invite-flow-enabled of the 109 | access-management family is enabled for workspaces._ 110 | 111 | ``` 112 | $ cat ./standard/1/gates/access-management/invite-flow-enabled/workspace 113 | open true 114 | salt 3653824901 115 | volume 1 116 | ``` 117 | 118 | The gate files contain key value pairs for the few properties of a gate, 119 | which determine which of the identifiers will see the gate open or closed. 120 | 121 | | Key | Value | 122 | | ------ | -------------------------------------------------------------------------------------------------- | 123 | | open | true/false, indicates the default behavior for identifiers that are not in the collection file | 124 | | salt | random value injected in the hash function used to determine the gate open state | 125 | | volume | floating point number between 0 and 1 defining the volume of identifiers that the gate is open for | 126 | 127 | ## Using the CLI 128 | 129 | The `cmd/feature` program can be used to explore the state of a feature 130 | database. The CLI has multiple subcommands, we present a few useful ones 131 | in this section. 132 | 133 | All subcommand understand the following options: 134 | 135 | | Option | Environment Variable | Description | 136 | | -------------- | -------------------- | ----------------------------------------------- | 137 | | `-p`, `--path` | `FEATURE_PATH` | Path to the feature database to run commands on | 138 | 139 | The `FEATURE_PATH` environment variable provides a simple mechanism to configure 140 | configure the _default_ database used by the command: 141 | 142 | ```shell 143 | $ export FEATURE_PATH=/path/to/features 144 | ``` 145 | 146 | By default, the `$HOME/feature` directory is used. 147 | 148 | ### `feature get gates [collection] [id]` 149 | 150 | This command prints the list of gates enabled for an identifier, it is useful 151 | to determine whether a gate is open for a given id, for example: 152 | 153 | ``` 154 | # empty output if the gate is not open 155 | $ feature get gates source B458ru47n7 | grep gate-family | grep gate-name 156 | ``` 157 | 158 | ### `feature get tiers` 159 | 160 | This command prints a summary of the tiers that exist in the feature database, 161 | here is an example: 162 | 163 | ``` 164 | $ feature get tiers 165 | GROUP TIER COLLECTIONS FAMILIES GATES 166 | standard 7 0 17 39 167 | standard 6 0 18 40 168 | standard 1 3 20 109 169 | standard 8 0 17 39 170 | standard 4 3 18 41 171 | standard 3 0 18 41 172 | standard 2 3 19 107 173 | standard 5 3 18 40 174 | ``` 175 | 176 | ### `feature describe collection [-g group] [-t tier] [collection]` 177 | 178 | This command prints the list of identifiers in a collection, with the option to 179 | filter on a group and tier; by default all groups and tiers are shown. 180 | 181 | ``` 182 | $ feature describe collection workspace 183 | 96x782dXhZmn6RpPJVDXgG 184 | 4o74gqFGmTgq7GS6EN3ZQJ 185 | mcYdYvfZQcUaid1CVdC9F3 186 | nRRroPD8pV3giaetjpDmu7 187 | 96x782dXhZmn6RpPJVDXgG 188 | 1232rt203 189 | 9a2aceada5 190 | cus_HbXktPfAbH3weZ 191 | opzvxHK692ZJJicNxz1AfL 192 | pkpdcdSLNX14Za6qpD7wtv 193 | ... 194 | ``` 195 | 196 | _Note: the identifiers are not displayed in any particular order, this command 197 | iterates over the directories and scans the collection files._ 198 | 199 | ### `feature describe tier [group] [tier]` 200 | 201 | This command shows a verbose description of a tier, including the list of 202 | collections, and the state of each gate in the tier: 203 | 204 | ``` 205 | $ feature describe tier standard 1 206 | Group: standard 207 | Tier: 1 208 | 209 | Collections: 210 | - write_key 211 | - workspace 212 | - source 213 | 214 | Gates: 215 | integrations-consumer/observability-discards-gate 216 | - workspace (100%, default: open) 217 | 218 | destinations-59ceac7c2828a60001d22936/centrifuge_qa 219 | - workspace (100%, default: open) 220 | 221 | destinations-54521fdc25e721e32a72ef04/webhook-flagon-centrifuge 222 | - write_key (100%, default: close) 223 | 224 | ... 225 | ``` 226 | 227 | ## Using the Go API 228 | 229 | The `feature` package provides APIs to consume the feature gate data set, this 230 | section presents on the most common use cases that programs have and how they 231 | are solved by the package. 232 | 233 | ```go 234 | import ( 235 | "github.com/segmentio/feature" 236 | ) 237 | ``` 238 | 239 | ### `feature.MountPoint` 240 | 241 | The `feature.MountPoint` type represents a path on the file system where a 242 | feature database is mounted. This type is the entry point to all other APIs, 243 | a common pattern is for programs to construct a mount point from a 244 | configuration option or environment variable: 245 | 246 | ```go 247 | mountPoint := feature.MountPoint("/path/to/features") 248 | ``` 249 | 250 | _Note: prefer using an absolute path for the mount point, so operations are 251 | not dependent on the working directory._ 252 | 253 | ### `feature.Store` 254 | 255 | From a mount point, a program can open a feature database, which is materialized 256 | by a `feature.Store` object. 257 | 258 | ```go 259 | features, err := mountPoint.Open() 260 | if err != nil { 261 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 262 | } else { 263 | ... 264 | } 265 | ``` 266 | 267 | The `feature.Store` type will watch for changes to the mount point, and 268 | automatically reload the content of the feature database when a change is 269 | detected. This mechanism assumes that the feature database is immutable, 270 | programs that intend to apply updates to the database must recreate it and 271 | replace the entire directory structure (which should be done in an atomic 272 | fashion via the use of the `rename(2)` syscall for example). 273 | 274 | ### `feature.(*Store).GateOpen` 275 | 276 | This is the most common use case for programs, the `GateOpen` method tests 277 | whether a gate is open for a given identifier. 278 | 279 | The gate is defined by the pair of gate family and name, while the identifier 280 | is expressed as a pair of the collection and its value. 281 | 282 | ```go 283 | if features.GateOpen("gate-family", "gate-name", "collection", "1234") { 284 | ... 285 | } 286 | ``` 287 | 288 | ### `feature.(*Store).LookupGates` 289 | 290 | Another common use case is for programs to lookup the list of gates that are 291 | enabled on an identifier. The `LookupGates` method solves for this use case. 292 | 293 | ```go 294 | for _, gate := range features.LookupGates("gate-family", "collection", "1234") { 295 | ... 296 | } 297 | ``` 298 | 299 | _Note: the `feature.Store` type uses an internal cache to optimize gate lookups, 300 | programs must treat the returned slice as an immutable value to avoid race 301 | conditions. If the slice needs to be modified, a copy must be made first._ 302 | --------------------------------------------------------------------------------