├── runtime.go ├── test └── runtime-development │ ├── name5 │ ├── name6 │ ├── name7 │ └── name ├── CODE_OF_CONDUCT.md ├── snapshot ├── entry │ └── entry.go ├── nil_test.go ├── mock_test.go ├── nil.go ├── snapshot_test.go ├── mock.go ├── iface.go └── snapshot.go ├── go.mod ├── loader ├── nil.go ├── nil_test.go ├── symlink_refresher.go ├── iface.go ├── refresher_iface.go ├── directory_refresher.go ├── loader.go └── loader_test.go ├── script ├── test └── install-glide ├── .github └── workflows │ └── test.yml ├── LICENSE ├── CONTRIBUTING.md ├── .gitignore ├── go.sum └── README.md /runtime.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | -------------------------------------------------------------------------------- /test/runtime-development/name5: -------------------------------------------------------------------------------- 1 | 6 -------------------------------------------------------------------------------- /test/runtime-development/name6: -------------------------------------------------------------------------------- 1 | 76 -------------------------------------------------------------------------------- /test/runtime-development/name7: -------------------------------------------------------------------------------- 1 | 100 -------------------------------------------------------------------------------- /test/runtime-development/name: -------------------------------------------------------------------------------- 1 | value -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | This project is governed by [Lyft's code of conduct](https://github.com/lyft/code-of-conduct). All contributors and participants agree to abide by its terms. 2 | -------------------------------------------------------------------------------- /snapshot/entry/entry.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import "time" 4 | 5 | // An individual snapshot entry. Optimized for integers by pre-converting them if possible. 6 | type Entry struct { 7 | StringValue string 8 | Uint64Value uint64 9 | Uint64Valid bool 10 | Modified time.Time 11 | } 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lyft/goruntime 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/fsnotify/fsnotify v1.4.9 7 | github.com/kelseyhightower/envconfig v1.4.0 // indirect 8 | github.com/lyft/gostats v0.4.1 9 | github.com/sirupsen/logrus v1.6.0 10 | github.com/stretchr/testify v1.2.2 11 | golang.org/x/sys v0.0.0-20200523222454-059865788121 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /loader/nil.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import "github.com/lyft/goruntime/snapshot" 4 | 5 | // Implementation of Loader with no backing store. 6 | type Nil struct { 7 | snapshot snapshot.Nil 8 | } 9 | 10 | func NewNil() Nil { 11 | return Nil{} 12 | } 13 | 14 | func (n Nil) Snapshot() snapshot.IFace { return n.snapshot } 15 | 16 | func (Nil) AddUpdateCallback(callback chan<- int) {} 17 | -------------------------------------------------------------------------------- /loader/nil_test.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "testing" 5 | "unsafe" 6 | ) 7 | 8 | func TestNil(t *testing.T) { 9 | allocs := testing.AllocsPerRun(100, func() { 10 | _ = NewNil() 11 | }) 12 | if allocs != 0 { 13 | t.Errorf("NewNil should not alloc got: %f", allocs) 14 | } 15 | if unsafe.Sizeof(Nil{}) != 0 { 16 | t.Errorf("Nil should have size 0 got: %d", unsafe.Sizeof(Nil{})) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /snapshot/nil_test.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "testing" 5 | "unsafe" 6 | ) 7 | 8 | func TestNil(t *testing.T) { 9 | allocs := testing.AllocsPerRun(100, func() { 10 | _ = NewNil() 11 | }) 12 | if allocs != 0 { 13 | t.Errorf("NewNil should not alloc got: %f", allocs) 14 | } 15 | if unsafe.Sizeof(Nil{}) != 0 { 16 | t.Errorf("Nil should have size 0 got: %d", unsafe.Sizeof(Nil{})) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /snapshot/mock_test.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMock_SetEnabled(t *testing.T) { 10 | m := NewMock().SetEnabled("thing") 11 | assert.True(t, m.FeatureEnabled("thing", 0)) 12 | 13 | m.SetDisabled("thing") 14 | assert.False(t, m.FeatureEnabled("thing", 0)) 15 | 16 | m.Set("other-thing", "value") 17 | assert.Equal(t, "value", m.Get("other-thing")) 18 | } 19 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | if [ ! -z "$SRCPATH" ] 3 | then 4 | cd $SRCPATH 5 | export PATH=/go/bin:$PATH 6 | fi 7 | 8 | PKGS=$(find . -maxdepth 3 -type d | sed s/\.\\/// | grep -vE '.git|\.|script|vendor') 9 | 10 | LINT_PKGS=$(echo ${PKGS}) 11 | for pkg in $LINT_PKGS; do 12 | golint $pkg | grep -v comment 13 | done 14 | 15 | go vet $(glide nv) 16 | goimports -d $(find . -type f -name '*.go' -not -path "./vendor/*") 17 | go test -race -cover $(glide nv) $@ 18 | -------------------------------------------------------------------------------- /loader/symlink_refresher.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import "path/filepath" 4 | 5 | type SymlinkRefresher struct { 6 | RuntimePath string 7 | } 8 | 9 | func (s *SymlinkRefresher) WatchDirectory(runtimePath string, appDirPath string) string { 10 | return filepath.Dir(runtimePath) 11 | } 12 | 13 | func (s *SymlinkRefresher) ShouldRefresh(path string, op FileSystemOp) bool { 14 | if path == s.RuntimePath && 15 | (op == Write || op == Create) { 16 | return true 17 | } 18 | return false 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v2 15 | - name: install go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.17 19 | - name: build 20 | run: go build -v ./... 21 | - name: test 22 | run: go test -v -race ./... 23 | -------------------------------------------------------------------------------- /loader/iface.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import "github.com/lyft/goruntime/snapshot" 4 | 5 | type IFace interface { 6 | // @return Snapshot the current snapshot. This reference is safe to use forever, but will grow 7 | // stale so should not be stored beyond when it is immediately needed. 8 | Snapshot() snapshot.IFace 9 | 10 | // Add a channel that will be written to when a new snapshot is available. "1" will be written 11 | // to the channel as a sentinel. 12 | // @param callback supplies the callback to add. 13 | AddUpdateCallback(callback chan<- int) 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | goruntime 2 | 3 | Copyright 2017 Lyft Inc. 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /script/install-glide: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | which glide > /dev/null 2>&1 && exit 0 5 | 6 | if test "Darwin" == "$(uname)" 7 | then brew install glide 8 | fi 9 | 10 | which glide > /dev/null 2>&1 || { 11 | mkdir -p ./glide 12 | 13 | curl -L https://github.com/Masterminds/glide/releases/download/v0.12.2/glide-v0.12.2-linux-amd64.tar.gz | tar xz -C ./glide --strip-components=1 14 | chmod 755 -R ./glide 15 | 16 | if which sudo >/dev/null; 17 | then sudo mv ./glide/glide /usr/local/bin/ 18 | else 19 | mv ./glide/glide /usr/local/bin/ 20 | fi 21 | } 22 | 23 | which glide > /dev/null 2>&1 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We welcome contributions from the community. Here are some guidelines. 2 | 3 | # Coding style 4 | 5 | * Goruntime uses golang's `fmt` too. 6 | 7 | # Submitting a PR 8 | 9 | * Fork the repo and create your PR. 10 | * Tests will automatically run for you. 11 | * When all of the tests are passing, tag @lyft/core-libraries and @lyft/observability and we will review it and 12 | merge once our CLA has been signed (see below). 13 | * Party time. 14 | 15 | # CLA 16 | 17 | * We require a CLA for code contributions, so before we can accept a pull request we need 18 | to have a signed CLA. Please visit our [CLA service](https://oss.lyft.com/cla) and follow 19 | the instructions to sign the CLA. 20 | -------------------------------------------------------------------------------- /loader/refresher_iface.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | type FileSystemOp int32 4 | 5 | // Filesystem operations that are monitored for changes 6 | const ( 7 | Create FileSystemOp = iota 8 | Write 9 | Remove 10 | Rename 11 | Chmod 12 | ) 13 | 14 | // A Refresher is used to determine when to refresh the runtime 15 | type Refresher interface { 16 | // @return The directory path to watch for changes. 17 | // @param runtimePath The root of the runtime path 18 | // @param appDirPath Any app specific path 19 | WatchDirectory(runtimePath string, appDirPath string) string 20 | 21 | // @return If the runtime needs to be refreshed 22 | // @param path The path that triggered the FileSystemOp 23 | // @param The Filesystem op that happened on the directory returned from WatchDirectory 24 | ShouldRefresh(path string, op FileSystemOp) bool 25 | } 26 | -------------------------------------------------------------------------------- /loader/directory_refresher.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import "path/filepath" 4 | 5 | type DirectoryRefresher struct { 6 | currDir string 7 | watchOps map[FileSystemOp]bool 8 | } 9 | 10 | var defaultFileSystemOps = map[FileSystemOp]bool{ 11 | Write: true, 12 | Create: true, 13 | Chmod: true, 14 | } 15 | 16 | func (d *DirectoryRefresher) WatchDirectory(runtimePath string, appDirPath string) string { 17 | d.currDir = filepath.Join(runtimePath, appDirPath) 18 | return d.currDir 19 | } 20 | 21 | func (d *DirectoryRefresher) WatchFileSystemOps(fsops ...FileSystemOp) { 22 | d.watchOps = map[FileSystemOp]bool{} 23 | for _, op := range fsops { 24 | d.watchOps[op] = true 25 | } 26 | } 27 | 28 | func (d *DirectoryRefresher) ShouldRefresh(path string, op FileSystemOp) bool { 29 | watchOps := d.watchOps 30 | if watchOps == nil { 31 | watchOps = defaultFileSystemOps 32 | } 33 | return filepath.Dir(path) == d.currDir && watchOps[op] 34 | } 35 | -------------------------------------------------------------------------------- /snapshot/nil.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lyft/goruntime/snapshot/entry" 7 | ) 8 | 9 | // Implementation of Snapshot for the nilLoaderImpl. 10 | type Nil struct{} 11 | 12 | func NewNil() Nil { return Nil{} } 13 | 14 | func (Nil) FeatureEnabled(_ string, defaultValue uint64) bool { 15 | return defaultRandomGenerator.Random()%100 < min(defaultValue, 100) 16 | } 17 | 18 | func (Nil) FeatureEnabledForID(string, uint64, uint32) bool { 19 | return true 20 | } 21 | 22 | func (Nil) Get(string) string { 23 | return "" 24 | } 25 | 26 | func (Nil) GetInteger(_ string, defaultValue uint64) uint64 { 27 | return defaultValue 28 | } 29 | 30 | func (Nil) GetModified(string) time.Time { 31 | return time.Time{} 32 | } 33 | 34 | func (Nil) Keys() []string { 35 | return []string{} 36 | } 37 | 38 | func (Nil) Entries() map[string]*entry.Entry { 39 | return map[string]*entry.Entry{} 40 | } 41 | 42 | func (Nil) SetEntry(string, *entry.Entry) {} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | data_sparse_backup 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # IDEA files 58 | .idea 59 | runtime.iml 60 | .DS_Store 61 | 62 | # Emacs Rulz! (false) 63 | *~ 64 | 65 | # Vim Rulz! (true) 66 | *.swp 67 | vendor 68 | -------------------------------------------------------------------------------- /snapshot/snapshot_test.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | "time" 7 | 8 | "github.com/lyft/goruntime/snapshot/entry" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRandomGeneratorImpl_Random_Race(t *testing.T) { 13 | rgi := &randomGeneratorImpl{random: rand.New(rand.NewSource(time.Now().UnixNano()))} 14 | 15 | go func() { 16 | for i := 0; i < 100; i++ { 17 | rgi.Random() 18 | } 19 | }() 20 | 21 | for i := 0; i < 100; i++ { 22 | rgi.Random() 23 | } 24 | } 25 | 26 | func TestSnapshot_FeatureEnabledForID(t *testing.T) { 27 | key := "test" 28 | ss := NewMock() 29 | ss.SetUInt64(key, 100) 30 | assert.True(t, ss.FeatureEnabledForID(key, 1, 100)) 31 | 32 | ss.SetUInt64(key, 0) 33 | assert.False(t, ss.FeatureEnabledForID(key, 1, 100)) 34 | 35 | enabled := 0 36 | for i := 1; i < 101; i++ { 37 | ss.SetUInt64(key, uint64(i)) 38 | if ss.FeatureEnabledForID(key, uint64(i), 100) { 39 | enabled++ 40 | } 41 | } 42 | 43 | assert.Equal(t, 47, enabled) 44 | } 45 | 46 | func TestSnapshot_FeatureEnabledForIDDisabled(t *testing.T) { 47 | key := "test" 48 | ss := NewMock() 49 | assert.True(t, ss.FeatureEnabledForID(key, 1, 100)) 50 | assert.False(t, ss.FeatureEnabledForID(key, 1, 0)) 51 | } 52 | 53 | func TestSnapshot_GetModified(t *testing.T) { 54 | ss := NewMock() 55 | 56 | assert.True(t, ss.GetModified("foo").IsZero()) 57 | 58 | now := time.Now() 59 | ss.entries["foo"] = &entry.Entry{Modified: now} 60 | assert.Equal(t, now, ss.GetModified("foo")) 61 | } 62 | -------------------------------------------------------------------------------- /snapshot/mock.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lyft/goruntime/snapshot/entry" 7 | ) 8 | 9 | // Mock provides a Snapshot implementation for testing 10 | type Mock struct { 11 | *Snapshot 12 | } 13 | 14 | // NewMock initializes a new Mock 15 | func NewMock() (s *Mock) { 16 | s = &Mock{ 17 | Snapshot: New(), 18 | } 19 | 20 | return 21 | } 22 | 23 | // SetEnabled overrides the entry for `key` to be enabled 24 | func (m *Mock) SetEnabled(key string) *Mock { 25 | m.Snapshot.entries[key] = &entry.Entry{ 26 | StringValue: key, 27 | Uint64Value: 0, 28 | Uint64Valid: true, 29 | Modified: time.Now(), 30 | } 31 | 32 | return m 33 | } 34 | 35 | // SetDisabled overrides the entry for `key` to be disabled 36 | func (m *Mock) SetDisabled(key string) *Mock { 37 | m.Snapshot.entries[key] = &entry.Entry{ 38 | StringValue: key, 39 | Uint64Value: 0, 40 | Uint64Valid: false, 41 | Modified: time.Now(), 42 | } 43 | 44 | return m 45 | } 46 | 47 | // Set set the entry for `key` to `val` 48 | func (m *Mock) Set(key string, val string) *Mock { 49 | m.Snapshot.entries[key] = &entry.Entry{ 50 | StringValue: val, 51 | Uint64Value: 0, 52 | Uint64Valid: false, 53 | Modified: time.Now(), 54 | } 55 | 56 | return m 57 | } 58 | 59 | // SetUInt64 set the entry for `key` to `val` as a uint64 60 | func (m *Mock) SetUInt64(key string, val uint64) *Mock { 61 | m.Snapshot.entries[key] = &entry.Entry{ 62 | StringValue: "", 63 | Uint64Value: val, 64 | Uint64Valid: true, 65 | Modified: time.Now(), 66 | } 67 | 68 | return m 69 | } 70 | 71 | // FeatureEnabled overrides the internal `Snapshot`s `FeatureEnabled` 72 | func (m *Mock) FeatureEnabled(key string, defaultValue uint64) bool { 73 | if e, ok := m.Snapshot.entries[key]; ok { 74 | return e.Uint64Valid 75 | } 76 | 77 | return false 78 | } 79 | 80 | var _ IFace = &Mock{} 81 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 4 | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 5 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 6 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 7 | github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= 8 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 9 | github.com/lyft/gostats v0.4.1 h1:oR6p4HRCGxt0nUntmZIWmYMgyothBi3eZH2A71vRjsc= 10 | github.com/lyft/gostats v0.4.1/go.mod h1:Tpx2xRzz4t+T2Tx0xdVgIoBdR2UMVz+dKnE3X01XSd8= 11 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 12 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 13 | github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= 14 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 15 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 16 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 17 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o= 20 | golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | -------------------------------------------------------------------------------- /snapshot/iface.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/lyft/goruntime/snapshot/entry" 7 | ) 8 | 9 | // Snapshot provides the currently loaded set of runtime values. 10 | type IFace interface { 11 | // Test if a feature is enabled using the built in random generator. This is done by generating 12 | // a random number in the range 0-99 and seeing if this number is < the value stored in the 13 | // runtime key, or the default_value if the runtime key is invalid. 14 | // 15 | // NOTE: Although a snapshot represents a stable snapshot *of the contents in runtime*, the behavior of 16 | // this function, by design, includes performing a pseudo-random dice roll upon every call to it. 17 | // As a result, despite the fact that the underlying snapshot is not changing, the result of calling 18 | // this function repeatedly with the same parameter will *not* necessarily yield the same result. 19 | // Callers must be careful not to assume that multiple calls with result in a consistent return value. 20 | // 21 | // In other words, the snapshot provides a fixed *probability* of a particular result, but the result 22 | // will still vary across calls based on that probability. 23 | // 24 | // @param key supplies the feature key to lookup. 25 | // @param defaultValue supplies the default value that will be used if either the feature key 26 | // does not exist or it is not an integer. 27 | // @return true if the feature is enabled. 28 | FeatureEnabled(key string, defaultValue uint64) bool 29 | 30 | // FeatureEnabledForID checks that the crc32 of the id and key's byte value falls within the mod of 31 | // the 0-100 value for the given feature. Use this method for "sticky" features 32 | // @param key supplies the feature key to lookup. 33 | // @param id supplies the ID to use in the CRC check. 34 | // @param defaultValue supplies the default value that will be used if either the feature key 35 | // does not exist or it is not a valid percentage. 36 | FeatureEnabledForID(key string, id uint64, defaultPercentage uint32) bool 37 | 38 | // Fetch raw runtime data based on key. 39 | // @param key supplies the key to fetch. 40 | // @return const std::string& the value or empty string if the key does not exist. 41 | Get(key string) string 42 | 43 | // Fetch an integer runtime key. 44 | // @param key supplies the key to fetch. 45 | // @param defaultValue supplies the value to return if the key does not exist or it does not 46 | // contain an integer. 47 | // @return uint64 the runtime value or the default value. 48 | GetInteger(key string, defaultValue uint64) uint64 49 | 50 | // GetModified returns the last modified timestamp for key. If key does not 51 | // exist, the zero value for time.Time is returned. 52 | GetModified(key string) time.Time 53 | 54 | // Fetch all keys inside the snapshot. 55 | // @return []string all of the keys. 56 | Keys() []string 57 | 58 | Entries() map[string]*entry.Entry 59 | 60 | SetEntry(string, *entry.Entry) 61 | } 62 | -------------------------------------------------------------------------------- /snapshot/snapshot.go: -------------------------------------------------------------------------------- 1 | package snapshot 2 | 3 | import ( 4 | "encoding/binary" 5 | "hash/crc32" 6 | "math/rand" 7 | "sync" 8 | "time" 9 | 10 | "github.com/lyft/goruntime/snapshot/entry" 11 | ) 12 | 13 | func min(lhs uint64, rhs uint64) uint64 { 14 | if lhs < rhs { 15 | return lhs 16 | } else { 17 | return rhs 18 | } 19 | } 20 | 21 | // Random number generator. Implementations should be thread safe. 22 | type RandomGenerator interface { 23 | // @return uint64 a new random number. 24 | Random() uint64 25 | } 26 | 27 | // Implementation of RandomGenerator that uses a time seeded random generator. 28 | type randomGeneratorImpl struct { 29 | sync.Mutex 30 | random *rand.Rand 31 | } 32 | 33 | func (r *randomGeneratorImpl) Random() uint64 { 34 | r.Lock() 35 | v := uint64(r.random.Int63()) 36 | r.Unlock() 37 | return v 38 | } 39 | 40 | var defaultRandomGenerator RandomGenerator = &randomGeneratorImpl{ 41 | random: rand.New(rand.NewSource(time.Now().UnixNano())), 42 | } 43 | 44 | // Implementation of Snapshot for the filesystem loader. 45 | type Snapshot struct { 46 | entries map[string]*entry.Entry 47 | } 48 | 49 | func New() (s *Snapshot) { 50 | s = &Snapshot{ 51 | entries: make(map[string]*entry.Entry), 52 | } 53 | 54 | return 55 | } 56 | 57 | func (s *Snapshot) FeatureEnabled(key string, defaultValue uint64) bool { 58 | return defaultRandomGenerator.Random()%100 < min(s.GetInteger(key, defaultValue), 100) 59 | } 60 | 61 | // FeatureEnabledForID checks that the crc32 of the id and key's byte value falls within the mod of 62 | // the 0-100 value for the given feature. Use this method for "sticky" features 63 | func (s *Snapshot) FeatureEnabledForID(key string, id uint64, defaultPercentage uint32) bool { 64 | if e, ok := s.Entries()[key]; ok { 65 | if e.Uint64Valid { 66 | return enabled(id, uint32(e.Uint64Value), key) 67 | } 68 | } 69 | 70 | return enabled(id, defaultPercentage, key) 71 | } 72 | 73 | func (s *Snapshot) Get(key string) string { 74 | e, ok := s.entries[key] 75 | if ok { 76 | return e.StringValue 77 | } else { 78 | return "" 79 | } 80 | } 81 | 82 | func (s *Snapshot) GetInteger(key string, defaultValue uint64) uint64 { 83 | e, ok := s.entries[key] 84 | if ok && e.Uint64Valid { 85 | return e.Uint64Value 86 | } else { 87 | return defaultValue 88 | } 89 | } 90 | 91 | // GetModified returns the last modified timestamp for key. If key does not 92 | // exist, the zero value for time.Time is returned. 93 | func (s *Snapshot) GetModified(key string) time.Time { 94 | if e, ok := s.entries[key]; ok { 95 | return e.Modified 96 | } 97 | 98 | return time.Time{} 99 | } 100 | 101 | func (s *Snapshot) Keys() []string { 102 | ret := []string{} 103 | for key := range s.entries { 104 | ret = append(ret, key) 105 | } 106 | return ret 107 | } 108 | 109 | func (s *Snapshot) Entries() map[string]*entry.Entry { 110 | return s.entries 111 | } 112 | 113 | func (s *Snapshot) SetEntry(key string, e *entry.Entry) { 114 | s.entries[key] = e 115 | } 116 | 117 | func enabled(id uint64, percentage uint32, feature string) bool { 118 | uid := crc(id, feature) 119 | 120 | return uid%100 < percentage 121 | } 122 | 123 | func crc(id uint64, feature string) uint32 { 124 | b := make([]byte, 8, len(feature)+8) 125 | binary.LittleEndian.PutUint64(b, id) 126 | b = append(b, []byte(feature)...) 127 | 128 | return crc32.ChecksumIEEE(b) 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 4 | 5 | - [Goruntime](#goruntime) 6 | - [Overview](#overview) 7 | - [Installation](#installation) 8 | - [Building](#building) 9 | - [Usage](#usage) 10 | - [Intended Use](#intended-use) 11 | - [Components](#components) 12 | - [Loader](#loader) 13 | - [Snapshot](#snapshot) 14 | - [Example of Usage](#example-of-usage) 15 | 16 | 17 | 18 | # Goruntime 19 | 20 | ## Overview 21 | 22 | Goruntime is a Go client for Runtime application level feature flags and configuration. 23 | 24 | ## Installation 25 | 26 | ``` 27 | go get github.com/lyft/goruntime 28 | ``` 29 | 30 | ## Usage 31 | 32 | In order to start using goruntime, import it to your project with: 33 | 34 | ```Go 35 | import "github.com/lyft/goruntime" 36 | ``` 37 | 38 | ### Intended Use 39 | 40 | The runtime system is meant to support small amounts of data, such 41 | as feature flags, kill switches, regional configuration, experiment 42 | settings, etc. Individual files should typically contain a single key/value pair 43 | (filename as key, content as value). 44 | 45 | ### Components 46 | 47 | The runtime system is composed of a Loader interface, Runtime interface and a Snapshot interface. The Snapshot holds a version of 48 | the runtime data from disk, and is used to retrieve information from that data. The Loader loads the current snapshot, and 49 | gets file system updates when the runtime data gets updated. The Loader also uses the Refresher to watch the runtime directory 50 | and refreshes the snapshot when prompted. 51 | 52 | #### Refresher 53 | The Refresher [interface](https://github.com/lyft/goruntime/blob/master/loader/refresher_iface.go) is defined like this: 54 | 55 | ```Go 56 | // A Refresher is used to determine when to refresh the runtime 57 | type Refresher interface { 58 | // @return The directory path to watch for changes. 59 | // @param runtimePath The root of the runtime path 60 | // @param appDirPath Any app specific path 61 | WatchDirectory(runtimePath string, appDirPath string) string 62 | 63 | // @return If the runtime needs to be refreshed 64 | // @param path The path that triggered the FileSystemOp 65 | // @param The Filesystem op that happened on the directory returned from WatchDirectory 66 | ShouldRefresh(path string, op FileSystemOp) bool 67 | } 68 | ``` 69 | 70 | The Refresher determines what directory to watch for file system changes and if there are any changes when to refresh. 71 | 72 | Two refreshers are provided out of the box 73 | * [Symlink Refresher](https://github.com/lyft/goruntime/blob/master/loader/symlink_refresher.go) : Watches the runtime directory as if it were a symlink and prompts a refresh if the symlink changes. 74 | * [Directory Refresher](https://github.com/lyft/goruntime/blob/master/loader/directory_refresher.go) : Watches the runtime directory as a regular directory and prompts a refresh if the content of that directory change (not its subdirectories). 75 | 76 | #### Loader 77 | 78 | The Loader [interface](https://github.com/lyft/goruntime/blob/master/loader/iface.go) is defined like this: 79 | 80 | ```Go 81 | type IFace interface { 82 | // @return Snapshot the current snapshot. This reference is safe to use forever, but will grow 83 | // stale so should not be stored beyond when it is immediately needed. 84 | Snapshot() snapshot.IFace 85 | 86 | // Add a channel that will be written to when a new snapshot is available. "1" will be written 87 | // to the channel as a sentinel. 88 | // @param callback supplies the callback to add. 89 | AddUpdateCallback(callback chan<- int) 90 | } 91 | ``` 92 | 93 | To create a new Loader: 94 | 95 | ```Go 96 | import ( 97 | "github.com/lyft/goruntime/loader" 98 | "github.com/lyft/gostats" 99 | ) 100 | 101 | // for full docs on gostats visit https://github.com/lyft/gostats 102 | store := stats.NewDefaultStore() 103 | runtime, err := loader.New2("runtime_path", "runtime_subdirectory", store.Scope("runtime"), &DirectoryRefresher{}, opts ...Option) 104 | if err != nil { 105 | // Handle error 106 | } 107 | ``` 108 | 109 | The Loader will use filesystem events to update the filesystem snapshot it has. 110 | 111 | **NOTE:** The old [`loader.New(...)`](https://github.com/lyft/goruntime/blob/fd5ff74f1c4313c29aa252a14626d37f0ad15e17/loader/loader.go#L218-L225) function is deprecated in favor of [`loader.New2(...)`](https://github.com/lyft/goruntime/blob/fd5ff74f1c4313c29aa252a14626d37f0ad15e17/loader/loader.go#L166-L216) which returns an error instead of panicking. 112 | 113 | ##### Loader Options 114 | 115 | `New2` is a variadic function that takes in arguments of type `Option`. These arguments are of the type `func(l *loader)` and 116 | are used to configure the `loader` being constructed. Dave Cheney wrote an [article](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) explaining this pattern. 117 | 118 | Currently the loader package provides the following `Option`s: 119 | 120 | 1. `AllowDotFiles`: the `loader` will take into account dot files when it builds a snapshot. 121 | 2. `IgnoreDotFiles`: the `loader` will ignore dot files when it builds a snapshot. 122 | 123 | #### Snapshot 124 | 125 | The Snapshot [interface](https://github.com/lyft/goruntime/blob/master/snapshot/iface.go) is defined like this: 126 | 127 | ```Go 128 | type IFace interface { 129 | FeatureEnabled(key string, defaultValue uint64) bool 130 | 131 | // Fetch raw runtime data based on key. 132 | // @param key supplies the key to fetch. 133 | // @return const std::string& the value or empty string if the key does not exist. 134 | Get(key string) string 135 | 136 | // Fetch an integer runtime key. 137 | // @param key supplies the key to fetch. 138 | // @param defaultValue supplies the value to return if the key does not exist or it does not 139 | // contain an integer. 140 | // @return uint64 the runtime value or the default value. 141 | GetInteger(key string, defaultValue uint64) uint64 142 | 143 | // Fetch all keys inside the snapshot. 144 | // @return []string all of the keys. 145 | Keys() []string 146 | 147 | Entries() map[string]*entry.Entry 148 | 149 | SetEntry(string, *entry.Entry) 150 | } 151 | ``` 152 | 153 | A Snapshot is composed of a map of [`Entry`s](https://github.com/lyft/goruntime/blob/master/snapshot/entry/entry.go). 154 | Each entry represents a file in the runtime path. The Snapshot can be used to `Get` the value of an entry (or `GetInteger` 155 | if the file contains an integer). 156 | 157 | Keys are built by joining paths with `.` relative to the runtime subdirectory. For example if this is your filesystem: 158 | 159 | ``` 160 | /runtime/ 161 | └── config 162 | ├── file1 163 | └── more_files 164 | ├── file2 165 | └── file3 166 | ``` 167 | 168 | And the runtime loader is setup like so: 169 | 170 | ```Go 171 | store := stats.NewDefaultStore() 172 | runtime, err := loader.New2("/runtime", "config", stats.Scope("runtime"), AllowDotFiles) 173 | if err != nil { 174 | // Handle error 175 | } 176 | ``` 177 | 178 | The values in all three files can be obtained the following way: 179 | 180 | ```Go 181 | s := runtime.Snapshot() 182 | s.Get("file1") 183 | s.Get("more_files.file2") 184 | //Supposed file3 contains an integer, or you want to use a default integer if file3 does not contain one 185 | s.GetInteger("more_files.file3", 8) 186 | ``` 187 | -------------------------------------------------------------------------------- /loader/loader.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | 13 | "github.com/fsnotify/fsnotify" 14 | "github.com/lyft/goruntime/snapshot" 15 | "github.com/lyft/goruntime/snapshot/entry" 16 | stats "github.com/lyft/gostats" 17 | 18 | logger "github.com/sirupsen/logrus" 19 | ) 20 | 21 | type loaderStats struct { 22 | loadAttempts stats.Counter 23 | loadFailures stats.Counter 24 | numValues stats.Gauge 25 | } 26 | 27 | func newLoaderStats(scope stats.Scope) loaderStats { 28 | ret := loaderStats{} 29 | ret.loadAttempts = scope.NewCounter("load_attempts") 30 | ret.loadFailures = scope.NewCounter("load_failures") 31 | ret.numValues = scope.NewGauge("num_values") 32 | return ret 33 | } 34 | 35 | type callbacks struct { 36 | mu sync.Mutex 37 | cbs []chan<- struct{} 38 | } 39 | 40 | func notifyCallback(notify <-chan struct{}, callback chan<- int) { 41 | for range notify { 42 | callback <- 1 // potentially blocking send 43 | } 44 | } 45 | 46 | func (c *callbacks) Add(callback chan<- int) { 47 | // 48 | // We cannot rely on sends to the user provided callback to not block and 49 | // we guarantee that the callback will be signaled if there is a runtime 50 | // change. 51 | // 52 | // The issue is that if the user provided callback blocks, we deadlock. 53 | // 54 | // To handle this we use our own buffered channel and a separate goroutine 55 | // to signal the callback. If the callback blocks it may not be signaled 56 | // for every update, but it will be signaled at least once. This is close 57 | // enough to the original API contract to warrant the change and prevent 58 | // deadlocks. 59 | // 60 | notify := make(chan struct{}, 1) 61 | c.mu.Lock() 62 | c.cbs = append(c.cbs, notify) 63 | c.mu.Unlock() 64 | go notifyCallback(notify, callback) 65 | } 66 | 67 | // Signal all callback channels without blocking. 68 | func (c *callbacks) Signal() { 69 | c.mu.Lock() 70 | for _, ch := range c.cbs { 71 | select { 72 | case ch <- struct{}{}: 73 | // The callback will be signaled (at some point). 74 | default: 75 | // We're still waiting for a previous signal to be sent, dropping 76 | // this signal. 77 | } 78 | } 79 | c.mu.Unlock() 80 | } 81 | 82 | // Implementation of Loader that watches a symlink and reads from the filesystem. 83 | type Loader struct { 84 | currentSnapshot atomic.Value 85 | watcher *fsnotify.Watcher 86 | watchPath string 87 | subdirectory string 88 | nextSnapshot snapshot.IFace 89 | callbacks callbacks 90 | mu sync.Mutex 91 | stats loaderStats 92 | ignoreDotfiles bool 93 | } 94 | 95 | func (l *Loader) Snapshot() snapshot.IFace { 96 | v, _ := l.currentSnapshot.Load().(snapshot.IFace) 97 | return v 98 | } 99 | 100 | func (l *Loader) AddUpdateCallback(callback chan<- int) { 101 | if callback == nil { 102 | panic("goruntime/loader: nil callback") 103 | } 104 | l.callbacks.Add(callback) 105 | } 106 | 107 | func (l *Loader) onRuntimeChanged() { 108 | targetDir := filepath.Join(l.watchPath, l.subdirectory) 109 | 110 | l.nextSnapshot = snapshot.New() 111 | filepath.Walk(targetDir, l.walkDirectoryCallback) 112 | 113 | l.stats.loadAttempts.Inc() 114 | l.stats.numValues.Set(uint64(len(l.nextSnapshot.Entries()))) 115 | l.currentSnapshot.Store(l.nextSnapshot) 116 | 117 | l.nextSnapshot = nil 118 | l.callbacks.Signal() 119 | } 120 | 121 | type walkError struct { 122 | err error 123 | } 124 | 125 | func (l *Loader) walkDirectoryCallback(path string, info os.FileInfo, err error) error { 126 | if err != nil { 127 | l.stats.loadFailures.Inc() 128 | logger.Warnf("runtime: error processing %s: %s", path, err) 129 | 130 | return nil 131 | } 132 | 133 | if l.ignoreDotfiles && info.IsDir() && strings.HasPrefix(info.Name(), ".") { 134 | return filepath.SkipDir 135 | } 136 | 137 | if !info.IsDir() { 138 | if l.ignoreDotfiles && strings.HasPrefix(info.Name(), ".") { 139 | return nil 140 | } 141 | 142 | contents, err := ioutil.ReadFile(path) 143 | 144 | if err != nil { 145 | l.stats.loadFailures.Inc() 146 | logger.Warnf("runtime: error reading %s: %s", path, err) 147 | 148 | return nil 149 | } 150 | 151 | key, err := filepath.Rel(filepath.Join(l.watchPath, l.subdirectory), path) 152 | 153 | if err != nil { 154 | l.stats.loadFailures.Inc() 155 | logger.Warnf("runtime: error parsing path %s: %s", path, err) 156 | 157 | return nil 158 | } 159 | 160 | key = strings.Replace(key, "/", ".", -1) 161 | stringValue := string(contents) 162 | e := &entry.Entry{ 163 | StringValue: stringValue, 164 | Uint64Value: 0, 165 | Uint64Valid: false, 166 | Modified: info.ModTime(), 167 | } 168 | 169 | uint64Value, err := strconv.ParseUint(strings.TrimSpace(stringValue), 10, 64) 170 | if err == nil { 171 | e.Uint64Value = uint64Value 172 | e.Uint64Valid = true 173 | } 174 | 175 | l.nextSnapshot.SetEntry(key, e) 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func getFileSystemOp(ev fsnotify.Event) FileSystemOp { 182 | switch ev.Op { 183 | case ev.Op & fsnotify.Write: 184 | return Write 185 | case ev.Op & fsnotify.Create: 186 | return Create 187 | case ev.Op & fsnotify.Chmod: 188 | return Chmod 189 | case ev.Op & fsnotify.Remove: 190 | return Remove 191 | case ev.Op & fsnotify.Rename: 192 | return Rename 193 | } 194 | return -1 195 | } 196 | 197 | type Option func(l *Loader) 198 | 199 | func AllowDotFiles(l *Loader) { l.ignoreDotfiles = false } 200 | func IgnoreDotFiles(l *Loader) { l.ignoreDotfiles = true } 201 | 202 | func New2(runtimePath, runtimeSubdirectory string, scope stats.Scope, refresher Refresher, opts ...Option) (IFace, error) { 203 | if runtimePath == "" || runtimeSubdirectory == "" { 204 | logger.Warn("no runtime configuration. using nil loader.") 205 | return NewNil(), nil 206 | } 207 | watchedPath := refresher.WatchDirectory(runtimePath, runtimeSubdirectory) 208 | 209 | watcher, err := fsnotify.NewWatcher() 210 | if err != nil { 211 | // If this fails with EMFILE (0x18) it is likely due to 212 | // inotify_init1() and fs.inotify.max_user_instances. 213 | // 214 | // Include the error message, type and value - this is 215 | // particularly useful if the error is a syscall.Errno. 216 | return nil, fmt.Errorf("unable to create runtime watcher: %[1]s (%[1]T %#[1]v)\n", err) 217 | } 218 | 219 | err = watcher.Add(watchedPath) 220 | if err != nil { 221 | return nil, fmt.Errorf("unable to watch file (%[1]s): %[2]s (%[2]T %#[2]v)", watchedPath, err) 222 | } 223 | 224 | newLoader := Loader{ 225 | watcher: watcher, 226 | watchPath: runtimePath, 227 | subdirectory: runtimeSubdirectory, 228 | stats: newLoaderStats(scope), 229 | } 230 | 231 | for _, opt := range opts { 232 | opt(&newLoader) 233 | } 234 | 235 | newLoader.onRuntimeChanged() 236 | 237 | go func() { 238 | for { 239 | select { 240 | case ev := <-watcher.Events: 241 | if refresher.ShouldRefresh(ev.Name, getFileSystemOp(ev)) { 242 | newLoader.onRuntimeChanged() 243 | } 244 | case err := <-watcher.Errors: 245 | logger.Warnf("runtime watch error: %s", err) 246 | } 247 | } 248 | }() 249 | 250 | return &newLoader, nil 251 | } 252 | 253 | // Deprecated: use New2 instead 254 | func New(runtimePath string, runtimeSubdirectory string, scope stats.Scope, refresher Refresher, opts ...Option) IFace { 255 | loader, err := New2(runtimePath, runtimeSubdirectory, scope, refresher, opts...) 256 | if err != nil { 257 | logger.Panic(err) 258 | } 259 | return loader 260 | } 261 | -------------------------------------------------------------------------------- /loader/loader_test.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | "sync/atomic" 9 | "testing" 10 | 11 | "sort" 12 | 13 | "time" 14 | 15 | stats "github.com/lyft/gostats" 16 | logger "github.com/sirupsen/logrus" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | var nullScope = stats.NewStore(stats.NewNullSink(), false) 21 | 22 | func init() { 23 | lvl, _ := logger.ParseLevel("DEBUG") 24 | logger.SetLevel(lvl) 25 | } 26 | 27 | func makeFileInDir(assert *require.Assertions, path string, text string) { 28 | tmpdir, err := ioutil.TempDir("", "") 29 | assert.NoError(err) 30 | defer os.RemoveAll(tmpdir) 31 | 32 | tmpfile := filepath.Join(tmpdir, filepath.Base(path)) 33 | 34 | err = ioutil.WriteFile(tmpfile, []byte(text), os.ModePerm) 35 | assert.NoError(err) 36 | 37 | err = os.MkdirAll(filepath.Dir(path), os.ModeDir|os.ModePerm) 38 | assert.NoError(err) 39 | 40 | // We use rename since creating a file and writing to it is too slow. 41 | // This is because creating the directory triggers the loader's watcher 42 | // causing it to scan the directory and if we need to create + write to 43 | // the file there is a chance the loader will store the contents of an 44 | // empty file, which is a race. 45 | // 46 | // This is okay because in prod we symlink files into place so we don't 47 | // need to worry about reading empty/partial files. 48 | // 49 | err = os.Rename(tmpfile, path) 50 | assert.NoError(err) 51 | } 52 | 53 | func TestNilRuntime(t *testing.T) { 54 | assert := require.New(t) 55 | 56 | loader := New("", "", nullScope, &SymlinkRefresher{RuntimePath: ""}, AllowDotFiles) 57 | snapshot := loader.Snapshot() 58 | assert.Equal("", snapshot.Get("foo")) 59 | assert.Equal(uint64(100), snapshot.GetInteger("bar", 100)) 60 | assert.True(snapshot.FeatureEnabled("baz", 100)) 61 | assert.False(snapshot.FeatureEnabled("blah", 0)) 62 | } 63 | 64 | func TestSymlinkRefresher(t *testing.T) { 65 | assert := require.New(t) 66 | 67 | // Setup base test directory. 68 | tempDir, err := ioutil.TempDir("", "runtime_test") 69 | assert.NoError(err) 70 | defer os.RemoveAll(tempDir) 71 | 72 | // Make test files for first runtime snapshot. 73 | makeFileInDir(assert, tempDir+"/testdir1/app/file1", "hello") 74 | makeFileInDir(assert, tempDir+"/testdir1/app/dir/file2", "world") 75 | makeFileInDir(assert, tempDir+"/testdir1/app/dir2/file3", "\n 34 ") 76 | assert.NoError(err) 77 | err = os.Symlink(tempDir+"/testdir1", tempDir+"/current") 78 | assert.NoError(err) 79 | 80 | loader := New(tempDir+"/current", "app", nullScope, &SymlinkRefresher{RuntimePath: tempDir + "/current"}, AllowDotFiles) 81 | runtime_update := make(chan int) 82 | loader.AddUpdateCallback(runtime_update) 83 | snapshot := loader.Snapshot() 84 | assert.Equal("", snapshot.Get("foo")) 85 | assert.Equal(uint64(5), snapshot.GetInteger("foo", 5)) 86 | assert.Equal("hello", snapshot.Get("file1")) 87 | assert.Equal(uint64(6), snapshot.GetInteger("file1", 6)) 88 | assert.Equal("world", snapshot.Get("dir.file2")) 89 | assert.Equal(uint64(7), snapshot.GetInteger("dir.file2", 7)) 90 | assert.Equal(uint64(34), snapshot.GetInteger("dir2.file3", 100)) 91 | 92 | info, _ := os.Stat(tempDir + "/testdir1/app/file1") 93 | assert.Equal(info.ModTime(), snapshot.GetModified("file1")) 94 | 95 | keys := snapshot.Keys() 96 | sort.Strings(keys) 97 | assert.EqualValues([]string{"dir.file2", "dir2.file3", "file1"}, keys) 98 | 99 | //// Make test files for second runtime snapshot. 100 | makeFileInDir(assert, tempDir+"/testdir2/app/file1", "hello2") 101 | makeFileInDir(assert, tempDir+"/testdir2/app/dir/file2", "world2") 102 | makeFileInDir(assert, tempDir+"/testdir2/app/dir2/file3", "100") 103 | err = os.Symlink(tempDir+"/testdir2", tempDir+"/current_new") 104 | assert.NoError(err) 105 | err = os.Rename(tempDir+"/current_new", tempDir+"/current") 106 | assert.NoError(err) 107 | 108 | <-runtime_update 109 | 110 | time.Sleep(100 * time.Millisecond) 111 | 112 | snapshot = loader.Snapshot() 113 | assert.Equal("", snapshot.Get("foo")) 114 | assert.Equal("hello2", snapshot.Get("file1")) 115 | assert.Equal("world2", snapshot.Get("dir.file2")) 116 | assert.Equal(uint64(100), snapshot.GetInteger("dir2.file3", 0)) 117 | assert.True(snapshot.FeatureEnabled("dir2.file3", 0)) 118 | 119 | keys = snapshot.Keys() 120 | sort.Strings(keys) 121 | assert.EqualValues([]string{"dir.file2", "dir2.file3", "file1"}, keys) 122 | } 123 | 124 | func TestIgnoreDotfiles(t *testing.T) { 125 | assert := require.New(t) 126 | 127 | // Setup base test directory. 128 | tempDir, err := ioutil.TempDir("", "runtime_test") 129 | assert.NoError(err) 130 | defer os.RemoveAll(tempDir) 131 | 132 | // Make test files for runtime snapshot. 133 | makeFileInDir(assert, tempDir+"/testdir1/app/dir3/.file4", ".file4") 134 | makeFileInDir(assert, tempDir+"/testdir1/app/.dir/file5", ".dir") 135 | assert.NoError(err) 136 | 137 | loaderIgnoreDotfiles := New(tempDir+"/testdir1", "app", nullScope, &SymlinkRefresher{RuntimePath: tempDir + "/testdir1"}, IgnoreDotFiles) 138 | snapshot := loaderIgnoreDotfiles.Snapshot() 139 | assert.Equal("", snapshot.Get("dir3..file4")) 140 | assert.Equal("", snapshot.Get(".dir.file5")) 141 | 142 | loaderIncludeDotfiles := New(tempDir+"/testdir1", "app", nullScope, &SymlinkRefresher{RuntimePath: tempDir + "/testdir1"}, AllowDotFiles) 143 | snapshot = loaderIncludeDotfiles.Snapshot() 144 | assert.Equal(".file4", snapshot.Get("dir3..file4")) 145 | assert.Equal(".dir", snapshot.Get(".dir.file5")) 146 | } 147 | 148 | func TestDirectoryRefresher(t *testing.T) { 149 | assert := require.New(t) 150 | 151 | // Setup base test directory. 152 | tempDir, err := ioutil.TempDir("", "dir_runtime_test") 153 | assert.NoError(err) 154 | defer os.RemoveAll(tempDir) 155 | 156 | appDir := tempDir + "/app" 157 | err = os.MkdirAll(appDir, os.ModeDir|os.ModePerm) 158 | assert.NoError(err) 159 | 160 | loader := New(tempDir, "app", nullScope, &DirectoryRefresher{}, AllowDotFiles) 161 | runtime_update := make(chan int) 162 | loader.AddUpdateCallback(runtime_update) 163 | snapshot := loader.Snapshot() 164 | assert.Equal("", snapshot.Get("file1")) 165 | makeFileInDir(assert, appDir+"/file1", "hello") 166 | 167 | // Wait for the update 168 | <-runtime_update 169 | 170 | snapshot = loader.Snapshot() 171 | assert.Equal("hello", snapshot.Get("file1")) 172 | 173 | // Mimic a file change in directory 174 | makeFileInDir(assert, appDir+"/file2", "hello2") 175 | 176 | // Wait for the update 177 | <-runtime_update 178 | 179 | snapshot = loader.Snapshot() 180 | assert.Equal("hello2", snapshot.Get("file2")) 181 | 182 | // Write to the file 183 | f, err := os.OpenFile(appDir+"/file2", os.O_RDWR, os.ModeAppend) 184 | assert.NoError(err) 185 | _, err = f.WriteString("hello3") 186 | assert.NoError(err) 187 | f.Sync() 188 | 189 | // Wait for the update 190 | <-runtime_update 191 | 192 | snapshot = loader.Snapshot() 193 | assert.Equal("hello3", snapshot.Get("file2")) 194 | } 195 | 196 | func TestOnRuntimeChanged(t *testing.T) { 197 | tmpdir, err := ioutil.TempDir("", "goruntime-*") 198 | if err != nil { 199 | t.Fatal(err) 200 | } 201 | 202 | defer func() { 203 | if err := os.RemoveAll(tmpdir); err != nil { 204 | t.Error(err) 205 | } 206 | }() 207 | 208 | dir, base := filepath.Split(tmpdir) 209 | ll := Loader{ 210 | watchPath: dir, 211 | subdirectory: base, 212 | stats: newLoaderStats(stats.NewStore(stats.NewNullSink(), false)), 213 | } 214 | 215 | const Timeout = time.Second * 3 216 | 217 | t.Run("Nil", func(t *testing.T) { 218 | defer func() { 219 | if e := recover(); e == nil { 220 | t.Fatal("expected panic") 221 | } 222 | }() 223 | ll.AddUpdateCallback(nil) 224 | }) 225 | 226 | t.Run("One", func(t *testing.T) { 227 | cb := make(chan int, 1) 228 | ll.AddUpdateCallback(cb) 229 | go ll.onRuntimeChanged() 230 | select { 231 | case i := <-cb: 232 | if i != 1 { 233 | t.Errorf("Callback: got: %d want: %d", i, 1) 234 | } 235 | case <-time.After(Timeout): 236 | t.Fatalf("Time out after: %s", Timeout) 237 | } 238 | }) 239 | 240 | t.Run("Blocking", func(t *testing.T) { 241 | done := make(chan struct{}) 242 | cb := make(chan int) 243 | ll.AddUpdateCallback(cb) 244 | go func() { 245 | ll.onRuntimeChanged() 246 | close(done) 247 | }() 248 | select { 249 | case <-done: 250 | // Ok 251 | case <-time.After(Timeout): 252 | t.Fatalf("Time out after: %s", Timeout) 253 | } 254 | }) 255 | 256 | t.Run("Many", func(t *testing.T) { 257 | cbs := make([]chan int, 10) 258 | for i := range cbs { 259 | cbs[i] = make(chan int, 1) 260 | ll.AddUpdateCallback(cbs[i]) 261 | } 262 | go ll.onRuntimeChanged() 263 | 264 | for _, cb := range cbs { 265 | select { 266 | case i := <-cb: 267 | if i != 1 { 268 | t.Errorf("Callback: got: %d want: %d", i, 1) 269 | } 270 | case <-time.After(Timeout): 271 | t.Fatalf("Time out after: %s", Timeout) 272 | } 273 | } 274 | }) 275 | 276 | t.Run("ManyDelayed", func(t *testing.T) { 277 | total := new(int64) 278 | wg := new(sync.WaitGroup) 279 | ready := make(chan struct{}) 280 | 281 | cbs := make([]chan int, 10) 282 | 283 | for i := range cbs { 284 | cbs[i] = make(chan int) // blocking 285 | ll.AddUpdateCallback(cbs[i]) 286 | wg.Add(1) 287 | go func(cb chan int) { 288 | defer wg.Done() 289 | <-ready 290 | atomic.AddInt64(total, int64(<-cb)) 291 | }(cbs[i]) 292 | } 293 | 294 | done := make(chan struct{}) 295 | go func() { 296 | ll.onRuntimeChanged() 297 | close(done) 298 | }() 299 | 300 | select { 301 | case <-done: 302 | // Ok 303 | case <-time.After(Timeout): 304 | t.Fatalf("Time out after: %s", Timeout) 305 | } 306 | if n := atomic.LoadInt64(total); n != 0 { 307 | t.Errorf("Expected %d channels to be signaled got: %d", 0, n) 308 | } 309 | close(ready) 310 | wg.Wait() 311 | 312 | if n := atomic.LoadInt64(total); n != 10 { 313 | t.Errorf("Expected %d channels to be signaled got: %d", 10, n) 314 | } 315 | }) 316 | 317 | t.Run("ManyBlocking", func(t *testing.T) { 318 | cbs := make([]chan int, 10) 319 | for i := range cbs { 320 | cbs[i] = make(chan int) 321 | ll.AddUpdateCallback(cbs[i]) 322 | } 323 | done := make(chan struct{}) 324 | go func() { 325 | ll.onRuntimeChanged() 326 | close(done) 327 | }() 328 | select { 329 | case <-done: 330 | // Ok 331 | case <-time.After(Timeout): 332 | t.Fatalf("Time out after: %s", Timeout) 333 | } 334 | }) 335 | } 336 | 337 | func TestShouldRefreshDefault(t *testing.T) { 338 | assert := require.New(t) 339 | 340 | refresher := DirectoryRefresher{currDir: "/tmp"} 341 | 342 | assert.True(refresher.ShouldRefresh("/tmp/foo", Write)) 343 | 344 | assert.False(refresher.ShouldRefresh("/tmp/foo", Remove)) 345 | assert.False(refresher.ShouldRefresh("/bar/foo", Write)) 346 | assert.False(refresher.ShouldRefresh("/bar/foo", Remove)) 347 | } 348 | 349 | func TestShouldRefreshRemove(t *testing.T) { 350 | assert := require.New(t) 351 | 352 | refresher := DirectoryRefresher{currDir: "/tmp", watchOps: map[FileSystemOp]bool{ 353 | Remove: true, 354 | Chmod: true, 355 | }} 356 | 357 | assert.True(refresher.ShouldRefresh("/tmp/foo", Remove)) 358 | assert.True(refresher.ShouldRefresh("/tmp/foo", Chmod)) 359 | 360 | assert.False(refresher.ShouldRefresh("/bar/foo", Write)) 361 | } 362 | 363 | func TestWatchFileSystemOps(t *testing.T) { 364 | assert := require.New(t) 365 | 366 | refresher := DirectoryRefresher{currDir: "/tmp"} 367 | 368 | refresher.WatchFileSystemOps() 369 | assert.False(refresher.ShouldRefresh("/tmp/foo", Write)) 370 | 371 | refresher.WatchFileSystemOps(Remove) 372 | assert.True(refresher.ShouldRefresh("/tmp/foo", Remove)) 373 | 374 | refresher.WatchFileSystemOps(Chmod, Write) 375 | assert.True(refresher.ShouldRefresh("/tmp/foo", Write)) 376 | } 377 | 378 | func BenchmarkSnapshot(b *testing.B) { 379 | var ll Loader 380 | for i := 0; i < b.N; i++ { 381 | ll.Snapshot() 382 | } 383 | } 384 | 385 | func BenchmarkSnapshot_Parallel(b *testing.B) { 386 | ll := new(Loader) 387 | b.RunParallel(func(pb *testing.PB) { 388 | for pb.Next() { 389 | ll.Snapshot() 390 | } 391 | }) 392 | } 393 | --------------------------------------------------------------------------------