Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: Atomically update file data source file #304

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 82 additions & 59 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
# python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.8"]

services:
redis:
Expand Down Expand Up @@ -49,6 +50,28 @@ jobs:
- name: Run tests
run: make test

- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test
- run: make test

- name: Verify typehints
run: make lint

Expand All @@ -67,61 +90,61 @@ jobs:
test_service_port: 9000
token: ${{ secrets.GITHUB_TOKEN }}

windows:
runs-on: windows-latest

defaults:
run:
shell: powershell

strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Setup DynamoDB
run: |
$ProgressPreference = "SilentlyContinue"
iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip
mkdir dynamo
Expand-Archive -Path dynamo.zip -DestinationPath dynamo
cd dynamo
cmd /c "START /b java -Djava.library.path=./DynamoDBLocal_lib -jar ./DynamoDBLocal.jar"

- name: Setup Consul
run: |
$ProgressPreference = "SilentlyContinue"
iwr -outf consul.zip https://releases.hashicorp.com/consul/1.4.2/consul_1.4.2_windows_amd64.zip
mkdir consul
Expand-Archive -Path consul.zip -DestinationPath consul
cd consul
sc.exe create "Consul" binPath="$(Get-Location)/consul.exe agent -dev"
sc.exe start "Consul"

- name: Setup Redis
run: |
$ProgressPreference = "SilentlyContinue"
iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip
mkdir redis
Expand-Archive -Path redis.zip -DestinationPath redis
cd redis
./redis-server --service-install
./redis-server --service-start
Start-Sleep -s 5
./redis-cli ping

- name: Install poetry
uses: abatilo/actions-poetry@7b6d33e44b4f08d7021a1dee3c044e9c253d6439

- name: Install requirements
run: poetry install --all-extras

- name: Run tests
run: make test
# windows:
# runs-on: windows-latest
#
# defaults:
# run:
# shell: powershell
#
# strategy:
# fail-fast: false
# matrix:
# python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
#
# steps:
# - uses: actions/checkout@v4
# - name: Set up Python ${{ matrix.python-version }}
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
#
# - name: Setup DynamoDB
# run: |
# $ProgressPreference = "SilentlyContinue"
# iwr -outf dynamo.zip https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.zip
# mkdir dynamo
# Expand-Archive -Path dynamo.zip -DestinationPath dynamo
# cd dynamo
# cmd /c "START /b java -Djava.library.path=./DynamoDBLocal_lib -jar ./DynamoDBLocal.jar"
#
# - name: Setup Consul
# run: |
# $ProgressPreference = "SilentlyContinue"
# iwr -outf consul.zip https://releases.hashicorp.com/consul/1.4.2/consul_1.4.2_windows_amd64.zip
# mkdir consul
# Expand-Archive -Path consul.zip -DestinationPath consul
# cd consul
# sc.exe create "Consul" binPath="$(Get-Location)/consul.exe agent -dev"
# sc.exe start "Consul"
#
# - name: Setup Redis
# run: |
# $ProgressPreference = "SilentlyContinue"
# iwr -outf redis.zip https://github.com/MicrosoftArchive/redis/releases/download/win-3.0.504/Redis-x64-3.0.504.zip
# mkdir redis
# Expand-Archive -Path redis.zip -DestinationPath redis
# cd redis
# ./redis-server --service-install
# ./redis-server --service-start
# Start-Sleep -s 5
# ./redis-cli ping
#
# - name: Install poetry
# uses: abatilo/actions-poetry@7b6d33e44b4f08d7021a1dee3c044e9c253d6439
#
# - name: Install requirements
# run: poetry install --all-extras
#
# - name: Run tests
# run: make test
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ install:
.PHONY: test
test: #! Run unit tests
test: install
@poetry run pytest $(PYTEST_FLAGS)
@poetry run pytest $(PYTEST_FLAGS) ldclient/testing/test_file_data_source.py

.PHONY: lint
lint: #! Run type analysis and linting checks
Expand Down
21 changes: 20 additions & 1 deletion ldclient/impl/integrations/files/file_data_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ def _load_all(self):
for path in self._paths:
try:
self._load_file(path, all_data)
except Exception as e:
except FileDataSourceEmpty:
log.warning('No flag data found in any file, continuing with current state')
return
except (Exception, FileNotFoundError) as e:
log.error('Unable to load flag data from "%s": %s' % (path, repr(e)))
traceback.print_exc()
if self._data_source_update_sink is not None:
Expand Down Expand Up @@ -112,6 +115,10 @@ def _load_file(self, path, all_data):
with open(path, 'r') as f:
content = f.read()
parsed = self._parse_content(content)

if parsed is None:
raise FileDataSourceEmpty()

for key, flag in parsed.get('flags', {}).items():
_sanitize_json_item(flag)
self._add_item(all_data, FEATURES, flag)
Expand Down Expand Up @@ -165,6 +172,13 @@ def __init__(self, resolved_paths, reloader):

class LDWatchdogHandler(watchdog.events.FileSystemEventHandler):
def on_any_event(self, event):
if isinstance(event, watchdog.events.FileDeletedEvent):
return

if isinstance(event, watchdog.events.FileMovedEvent) and event.dest_path in watched_files:
reloader()
return

if event.src_path in watched_files:
reloader()

Expand Down Expand Up @@ -213,4 +227,9 @@ def _check_file_times(self):
ret[path] = os.path.getmtime(path)
except:
ret[path] = None

return ret


class FileDataSourceEmpty(Exception):
pass
42 changes: 34 additions & 8 deletions ldclient/testing/test_file_data_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os
from typing import List

from ldclient.impl.util import log

import pytest
import tempfile
import threading
Expand Down Expand Up @@ -116,10 +118,14 @@ def make_temp_file(content):
os.close(f)
return path

def replace_file(path, content):
def update_file(path, content):
with open(path, 'w') as f:
f.write(content)

def replace_file(path, content):
new_file = make_temp_file(content)
os.replace(new_file, path)

def test_does_not_load_data_prior_to_start():
path = make_temp_file('{"flagValues":{"key":"value"}}')
try:
Expand Down Expand Up @@ -221,7 +227,7 @@ def test_does_not_allow_duplicate_keys():
os.remove(path1)
os.remove(path2)

def test_does_not_reload_modified_file_if_auto_update_is_off():
def test_does_not_reload_modified_file_if_auto_update_is_off_when_replacing_file():
path = make_temp_file(flag_only_json)
try:
source = make_data_source(Config("SDK_KEY"), paths = path)
Expand All @@ -234,15 +240,29 @@ def test_does_not_reload_modified_file_if_auto_update_is_off():
finally:
os.remove(path)

def do_auto_update_test(options):
def test_does_not_reload_modified_file_if_auto_update_is_off_when_updating_file():
path = make_temp_file(flag_only_json)
try:
source = make_data_source(Config("SDK_KEY"), paths = path)
source.start()
assert len(store.all(SEGMENTS, lambda x: x)) == 0
time.sleep(0.5)
update_file(path, segment_only_json)
time.sleep(0.5)
assert len(store.all(SEGMENTS, lambda x: x)) == 0
finally:
os.remove(path)

def do_auto_update_test(options, update_fn):
path = make_temp_file(flag_only_json)

options['paths'] = path
try:
source = make_data_source(Config("SDK_KEY"), **options)
source.start()
assert len(store.all(SEGMENTS, lambda x: x)) == 0
time.sleep(0.5)
replace_file(path, segment_only_json)
update_fn(path, segment_only_json)
deadline = time.time() + 20
while time.time() < deadline:
time.sleep(0.1)
Expand All @@ -252,11 +272,17 @@ def do_auto_update_test(options):
finally:
os.remove(path)

def test_reloads_modified_file_if_auto_update_is_on():
do_auto_update_test({ 'auto_update': True })
def test_reloads_modified_file_if_auto_update_is_on_when_replacing_file():
do_auto_update_test({ 'auto_update': True }, replace_file)

def test_reloads_modified_file_in_polling_mode_when_replacing_file():
do_auto_update_test({ 'auto_update': True, 'force_polling': True, 'poll_interval': 0.1 }, replace_file)

def test_reloads_modified_file_if_auto_update_is_on_when_updating_file():
do_auto_update_test({ 'auto_update': True }, update_file)

def test_reloads_modified_file_in_polling_mode():
do_auto_update_test({ 'auto_update': True, 'force_polling': True, 'poll_interval': 0.1 })
def test_reloads_modified_file_in_polling_mode_when_updating_file():
do_auto_update_test({ 'auto_update': True, 'force_polling': True, 'poll_interval': 0.1 }, update_file)

def test_evaluates_full_flag_with_client_as_expected():
path = make_temp_file(all_properties_json)
Expand Down
Loading