Dynamic Updates

In this tutorial, you’ll learn how to use ConfigStore for dynamic configuration updates, including watching for changes in etcd.

Learning Objectives

By the end of this tutorial, you’ll be able to:

  • Use ConfigStore for runtime configuration access

  • Subscribe to configuration changes

  • Enable automatic updates from watchable sources (e.g., etcd)

Step 1: Basic ConfigStore Usage

ConfigStore provides thread-safe access to configuration:

 1from dataclasses import dataclass, field
 2from varlord import Config
 3
 4@dataclass(frozen=True)
 5class AppConfig:
 6    host: str = field(default="0.0.0.0")
 7    port: int = field(default=8000)
 8
 9cfg = Config(
10    model=AppConfig,
11    sources=[],  # Defaults are automatically applied
12)
13
14# Load as store (supports dynamic updates)
15store = cfg.load_store()
16
17# Access configuration
18app = store.get()
19print(f"Host: {app.host}, Port: {app.port}")
20
21# Access via store.get() (store.host is not supported)
22print(f"Host: {store.get().host}, Port: {store.get().port}")

Expected Output:

Host: 0.0.0.0, Port: 8000
Host: 0.0.0.0, Port: 8000

Key Points:

  • load_store() returns a ConfigStore instance

  • store.get() returns the current configuration model instance

  • Access fields via store.get().host, not store.host

Step 2: Manual Reload

You can manually reload configuration:

 1import os
 2from dataclasses import dataclass, field
 3from varlord import Config, sources
 4
 5@dataclass(frozen=True)
 6class AppConfig:
 7    port: int = field(default=8000)
 8
 9cfg = Config(
10    model=AppConfig,
11    sources=[
12        sources.Env(),  # Defaults applied automatically
13    ],
14)
15
16store = cfg.load_store()
17
18# Initial value
19print(f"Initial port: {store.get().port}")
20
21# Change environment variable (no prefix needed)
22os.environ["PORT"] = "9000"
23
24# Manually reload
25store.reload()
26
27# New value
28print(f"Updated port: {store.get().port}")

Expected Output:

Initial port: 8000
Updated port: 9000

Important: Manual reload is useful when sources don’t support automatic watching (like environment variables or CLI arguments).

Step 3: Subscribing to Changes

You can subscribe to configuration changes:

 1import os
 2from dataclasses import dataclass, field
 3from varlord import Config, sources
 4
 5@dataclass(frozen=True)
 6class AppConfig:
 7    port: int = field(default=8000)
 8
 9def on_config_change(new_config, diff):
10    print(f"Configuration changed!")
11    print(f"  Added: {diff.added}")
12    print(f"  Modified: {diff.modified}")
13    print(f"  Deleted: {diff.deleted}")
14    print(f"  New port: {new_config.port}")
15
16cfg = Config(
17    model=AppConfig,
18    sources=[
19        sources.Env(),  # Defaults applied automatically
20    ],
21)
22
23store = cfg.load_store()
24store.subscribe(on_config_change)
25
26# Change configuration (no prefix needed)
27os.environ["PORT"] = "9000"
28store.reload()  # Triggers callback

Expected Output:

Configuration changed!
  Added: set()
  Modified: {'port'}
  Deleted: set()
  New port: 9000

Key Points:

  • Subscribers are called when configuration changes

  • diff object shows what changed

  • Callbacks receive the new configuration and diff

Step 4: Automatic Updates with Etcd (Optional)

If you have etcd installed, you can enable automatic watching:

 1from dataclasses import dataclass, field
 2from varlord import Config, sources
 3
 4@dataclass(frozen=True)
 5class AppConfig:
 6    port: int = field(default=8000)
 7    timeout: int = field(default=30)
 8
 9def on_config_change(new_config, diff):
10    print(f"Configuration updated from etcd!")
11    print(f"  Changes: {diff.modified}")
12
13# Configure etcd source with watch enabled
14# Option 1: Direct configuration
15cfg = Config(
16    model=AppConfig,
17    sources=[
18        sources.Etcd(
19            host="127.0.0.1",
20            port=2379,
21            prefix="/app/",
22            watch=True,  # Enable automatic watching
23            ca_cert="./cert/ca.cert.pem",  # TLS certificates (if needed)
24            cert_key="./cert/key.pem",
25            cert_cert="./cert/cert.pem",
26        ),  # Defaults applied automatically
27    ],
28)
29
30# Option 2: From environment variables (recommended)
31# Set ETCD_HOST, ETCD_PORT, ETCD_CA_CERT, etc. in environment
32# cfg = Config(
33#     model=AppConfig,
34#     sources=[
35#         sources.Etcd.from_env(prefix="/app/", watch=True),
36#     ],
37# )
38
39store = cfg.load_store()  # Automatically enables watch
40store.subscribe(on_config_change)
41
42print("Watching etcd for changes...")
43print("Update /app/host, /app/port, or /app/timeout in etcd to see changes")
44# In production, your application would continue running here

Note: This requires etcd to be running and the etcd3 package installed. Changes in etcd will automatically trigger configuration reloads and callbacks.

Key Points:

  • watch=True enables automatic watching

  • load_store() automatically detects and enables watch

  • Changes in etcd automatically update configuration

  • Subscribers are notified of changes

  • Watch is only enabled if the source supports it (via supports_watch())

Example: Watching Multiple Changes:

 1from dataclasses import dataclass, field
 2from varlord import Config, sources
 3
 4@dataclass(frozen=True)
 5class AppConfig:
 6    host: str = field()
 7    port: int = field(default=8000)
 8    debug: bool = field(default=False)
 9
10callbacks_received = []
11
12def on_config_change(new_config, diff):
13    callbacks_received.append((new_config, diff))
14    print(f"Change #{len(callbacks_received)}:")
15    print(f"  Modified: {diff.modified}")
16    print(f"  Config: {new_config.host}:{new_config.port}")
17
18cfg = Config(
19    model=AppConfig,
20    sources=[
21        sources.Etcd.from_env(prefix="/app/", watch=True),
22    ],
23)
24
25store = cfg.load_store()
26store.subscribe(on_config_change)
27
28# In etcd, make multiple changes:
29# 1. /app/host = "example.com"  -> triggers callback #1
30# 2. /app/port = "9000"         -> triggers callback #2
31# 3. /app/host = "updated.com"  -> triggers callback #3
32
33# All changes will trigger callbacks automatically

Example: Handling Added, Modified, and Deleted Keys:

 1@dataclass(frozen=True)
 2class AppConfig:
 3    host: str = field()
 4    port: int = field(default=8000)  # Default value
 5
 6def on_config_change(new_config, diff):
 7    if diff.added:
 8        print(f"New keys added: {diff.added}")
 9    if diff.modified:
10        print(f"Keys modified: {diff.modified}")
11        for key in diff.modified:
12            old_val, new_val = diff.modified[key]
13            print(f"  {key}: {old_val} -> {new_val}")
14    if diff.deleted:
15        print(f"Keys deleted: {diff.deleted}")
16        # Deleted keys fall back to default values
17
18cfg = Config(
19    model=AppConfig,
20    sources=[
21        sources.Etcd.from_env(prefix="/app/", watch=True),
22    ],
23)
24
25store = cfg.load_store()
26store.subscribe(on_config_change)
27
28# Example scenarios:
29# 1. Add new key: /app/port = "9000" -> diff.added contains "port"
30# 2. Modify key: /app/port = "8000" -> diff.modified contains "port"
31# 3. Delete key: etcdctl del /app/port -> diff.deleted contains "port"
32#    (port falls back to default 8000)

Example: Multiple Sources with Watch:

 1from varlord import Config, sources
 2
 3@dataclass(frozen=True)
 4class AppConfig:
 5    host: str = field()
 6    port: int = field(default=8000)
 7
 8cfg = Config(
 9    model=AppConfig,
10    sources=[
11        sources.Etcd(host="...", prefix="/app1/", watch=True),  # First source
12        sources.Etcd(host="...", prefix="/app2/", watch=True),  # Second source (overrides first)
13        sources.Env(),  # Env overrides both etcd sources
14    ],
15)
16
17store = cfg.load_store()  # Watch enabled for both etcd sources
18
19def on_change(new_config, diff):
20    print(f"Config updated: {new_config.host}:{new_config.port}")
21
22store.subscribe(on_change)
23
24# Changes in either etcd source will trigger callbacks
25# Priority: /app1/ < /app2/ < Env

Step 5: Thread-Safe Access

ConfigStore is thread-safe:

 1import threading
 2import time
 3from dataclasses import dataclass, field
 4from varlord import Config
 5
 6@dataclass(frozen=True)
 7class AppConfig:
 8    counter: int = field(default=0)
 9
10cfg = Config(
11    model=AppConfig,
12    sources=[],  # Defaults applied automatically
13)
14
15store = cfg.load_store()
16
17def reader_thread():
18    for _ in range(10):
19        config = store.get()
20        print(f"Reader: counter = {config.counter}")
21        time.sleep(0.1)
22
23# Start reader thread
24thread = threading.Thread(target=reader_thread)
25thread.start()
26
27# Main thread can also access safely
28for i in range(5):
29    print(f"Main: counter = {store.get().counter}")
30    time.sleep(0.2)
31
32thread.join()

Expected Output (example):

Main: counter = 0
Reader: counter = 0
Reader: counter = 0
Main: counter = 0
Reader: counter = 0
...

Key Points:

  • Multiple threads can safely access ConfigStore

  • Configuration snapshots are atomic

  • No locking required for reading

Step 6: Complete Example

Here’s a complete example with subscriptions and manual reload:

 1import os
 2import time
 3from dataclasses import dataclass, field
 4from varlord import Config, sources
 5
 6@dataclass(frozen=True)
 7class AppConfig:
 8    host: str = field(default="0.0.0.0")
 9    port: int = field(default=8000)
10    debug: bool = field(default=False)
11
12def on_config_change(new_config, diff):
13    print(f"\n[Callback] Configuration changed:")
14    if diff.modified:
15        for key in diff.modified:
16            print(f"  {key} was modified")
17    if diff.added:
18        for key in diff.added:
19            print(f"  {key} was added")
20    print(f"  Current config: {new_config.host}:{new_config.port}")
21
22def main():
23    cfg = Config(
24        model=AppConfig,
25        sources=[
26            sources.Env(),  # Defaults applied automatically
27        ],
28    )
29
30    store = cfg.load_store()
31    store.subscribe(on_config_change)
32
33    print("Initial configuration:")
34    config = store.get()
35    print(f"  {config.host}:{config.port} (debug={config.debug})")
36
37    # Simulate configuration changes
38    print("\n--- Changing port ---")
39    os.environ["PORT"] = "9000"
40    store.reload()
41
42    time.sleep(0.5)
43
44    print("\n--- Changing host ---")
45    os.environ["HOST"] = "192.168.1.1"
46    store.reload()
47
48    time.sleep(0.5)
49
50    print("\n--- Final configuration ---")
51    config = store.get()
52    print(f"  {config.host}:{config.port} (debug={config.debug})")
53
54if __name__ == "__main__":
55    main()

Expected Output:

Initial configuration:
  0.0.0.0:8000 (debug=False)

--- Changing port ---

[Callback] Configuration changed:
  port was modified
  Current config: 0.0.0.0:9000

--- Changing host ---

[Callback] Configuration changed:
  host was modified
  Current config: 192.168.1.1:9000

--- Final configuration ---
  192.168.1.1:9000 (debug=False)

Common Pitfalls

Pitfall 1: Expecting automatic updates from non-watchable sources

store = cfg.load_store()
# Change environment variable
os.environ["PORT"] = "9000"
# Configuration won't update automatically!
print(store.get().port)  # Still 8000

Solution: Environment variables and CLI arguments don’t support automatic watching. Use store.reload() to manually refresh, or use etcd for automatic updates.

Pitfall 2: Modifying configuration objects

config = store.get()
config.port = 9000  # This will fail - config is frozen!

Solution: Configuration objects are immutable (frozen). To change configuration, update the source (e.g., etcd, environment) and reload.

Pitfall 3: Not handling exceptions in callbacks

def on_config_change(new_config, diff):
    raise Exception("Error in callback")  # This won't break the update
    # But it will be silently ignored

Solution: Always handle exceptions in callbacks. The store will continue working even if a callback fails.

Best Practices

  1. Use ConfigStore for long-running applications: Enables dynamic updates

  2. Subscribe to changes: React to configuration updates

  3. Use etcd for automatic updates: When you need real-time configuration

  4. Handle reload errors gracefully: Updates are fail-safe

Next Steps

Now that you understand dynamic updates, let’s explore Advanced Features like custom priority policies and custom sources.