userver: Writing your own configs server
Loading...
Searching...
No Matches
Writing your own configs server

Before you start

Make sure that you can compile and run core tests and read a basic example Writing your first HTTP server.

Step by step guide

An ability to change service behavior at runtime without restarting the service is priceless! We have that ability, it is called dynamic configs and it allows you:

In previous example we made a simple HTTP server with some dynamic configs set in stone. To make the dynamic configs dynamic for real the following steps should be done:

# yaml
dynamic-config-client: # A client that knows how to request configs via HTTP
config-url: http://localhost:8083/ # URL of dynamic config service
http-retries: 5
http-timeout: 20s
service-name: configs-service
fallback-to-no-proxy: false # On error do not attempt to retrieve configs
# by bypassing proxy from USERVER_HTTP_PROXY dynamic config
dynamic-config-client-updater: # A component that periodically uses `dynamic-config-client` to retrieve new values
update-interval: 5s # Request for new configs every 5 seconds
full-update-interval: 1m
config-settings: false
first-update-fail-ok: true

Now let's create a configuration service.

HTTP handler component

Dynamic configs are requested via JSON requests, so we need to create a simple JSON handler that is responding with config values.

There are two ways to write a JSON handler:

We are going to take the second approach:

struct ConfigDataWithTimestamp {
std::chrono::system_clock::time_point updated_at;
std::unordered_map<std::string, formats::json::Value> key_values;
};
class ConfigDistributor final : public server::handlers::HttpHandlerJsonBase {
public:
static constexpr std::string_view kName = "handler-config";
using KeyValues = std::unordered_map<std::string, formats::json::Value>;
// Component is valid after construction and is able to accept requests
ConfigDistributor(const components::ComponentConfig& config,
formats::json::Value HandleRequestJsonThrow(
void SetNewValues(KeyValues&& key_values) {
config_values_.Assign(ConfigDataWithTimestamp{
/*.updated_at=*/utils::datetime::Now(),
/*.key_values=*/std::move(key_values),
});
}
private:
};
Warning
Handle* functions are invoked concurrently on the same instance of the handler class. Use synchronization primitives or do not modify shared data in Handle*.

Note the rcu::Variable. There may be (and will be!) multiple concurrent requests and the HandleRequestJsonThrow would be invoked concurrently on the same instance of ConfigDistributor. The rcu::Variable allows us to atomically update the config value, concurrently with the HandleRequestJsonThrow invocations.

Function ConfigDistributor::SetNewValues is meant for setting config values to send. For example, you can write another component that accepts JSON requests with new config values and puts those values into ConfigDistributor via SetNewValues(new_values).

HandleRequestJsonThrow

All the interesting things happen in the HandleRequestJsonThrow function, where we grab a rcu::Variable snapshot, fill the update time and the configuration from it:

const formats::json::Value& request);
formats::json::Value ConfigDistributor::HandleRequestJsonThrow(
const auto config_values_ptr = config_values_.Read();
result["configs"] = MakeConfigs(config_values_ptr, json);
const auto updated_at = config_values_ptr->updated_at;
result["updated_at"] = utils::datetime::Timestring(updated_at);
return result.ExtractValue();
}

The "configs" field is formed in the MakeConfigs function depending on the request parameters:

const formats::json::Value& request) {
const auto updated_since = request["updated_since"].As<std::string>({});
if (!updated_since.empty() && utils::datetime::Stringtime(updated_since) >=
config_values_ptr->updated_at) {
// Return empty JSON if "updated_since" is sent and no changes since then.
return configs;
}
LOG_DEBUG() << "Sending dynamic config for service "
<< request["service"].As<std::string>("<unknown>");
const auto& values = config_values_ptr->key_values;
if (request["ids"].IsMissing()) {
// Sending all the configs.
for (const auto& [key, value] : values) {
configs[key] = value;
}
return configs;
}
// Sending only the requested configs.
for (const auto& id : request["ids"]) {
const auto key = id.As<std::string>();
const auto it = values.find(key);
if (it != values.end()) {
configs[key] = it->second;
} else {
LOG_ERROR() << "Failed to find config with name '" << key << "'";
}
}
return configs;
}

Note that the service name is sent in the "service" field of the JSON request body. Using it you can make service specific dynamic configs.

Static config

Now we have to configure our new HTTP handle. The configuration is quite straightforward:

# yaml
handler-config:
path: /configs/values
method: POST # Only for HTTP POST requests. Other handlers may reuse the same URL but use different method.
task_processor: main-task-processor

int main()

Finally, we add required components to the components::MinimalServerComponentList(), and start the server with static config kStaticConfig.

int main(int argc, char* argv[]) {
const auto component_list = components::MinimalServerComponentList()
.Append<samples::ConfigDistributor>();
return utils::DaemonMain(argc, argv, component_list);
}

Build and Run

To build the sample, execute the following build steps at the userver root directory:

bash
mkdir build_release
cd build_release
cmake -DCMAKE_BUILD_TYPE=Release ..
make userver-samples-config_service

The sample could be started by running make start-userver-samples-config_service. The command would invoke testsuite start target that sets proper paths in the configuration files and starts the service.

To start the service manually run ./samples/config_service/userver-samples-config_service -c </path/to/static_config.yaml>.

Now you can send a request to your server from another terminal:

bash
$ curl -X POST -d '{}' 127.0.0.1:8083/configs/values | jq
{
"configs": {
"USERVER_DUMPS": {},
"USERVER_LRU_CACHES": {},
"USERVER_CACHES": {},
"USERVER_LOG_REQUEST": true,
"USERVER_TASK_PROCESSOR_QOS": {
"default-service": {
"default-task-processor": {
"wait_queue_overload": {
"action": "ignore",
"length_limit": 5000,
"time_limit_us": 3000
}
}
}
},
"USERVER_TASK_PROCESSOR_PROFILER_DEBUG": {},
"USERVER_LOG_REQUEST_HEADERS": true,
"USERVER_CANCEL_HANDLE_REQUEST_BY_DEADLINE": false,
"USERVER_HTTP_PROXY": ""
},
"updated_at": "2021-06-29T14:15:31.173239295+0000"
}
$ curl -X POST -d '{"ids":["USERVER_TASK_PROCESSOR_QOS"]}' 127.0.0.1:8083/configs/values | jq
{
"configs": {
"USERVER_TASK_PROCESSOR_QOS": {
"default-service": {
"default-task-processor": {
"wait_queue_overload": {
"action": "ignore",
"length_limit": 5000,
"time_limit_us": 3000
}
}
}
}
},
"updated_at": "2021-06-29T14:15:31.173239295+0000"
}

Functional testing

Functional tests for the service could be implemented using the service_client fixture in the following way:

async def test_config(service_client):
response = await service_client.post('/configs/values', json={})
assert response.status == 200
reply = response.json()
assert reply['configs']['USERVER_LOG_REQUEST_HEADERS'] is True
async def test_config_specific_ids(service_client):
response = await service_client.post(
'/configs/values',
json={
'updated_since': '2021-06-29T14:15:31.173239295+0000',
'ids': ['USERVER_TASK_PROCESSOR_QOS'],
},
)
assert response.status == 200
reply = response.json()
assert len(reply['configs']) == 1
assert reply['configs']['USERVER_TASK_PROCESSOR_QOS']

Do not forget to add the plugin in conftest.py:

# Adding a plugin from userver/testsuite/pytest_plugins/
pytest_plugins = ['pytest_userver.plugins.core']

Ready to use uservice-dynconf

Note that there is a ready to use opensource uservice-dynconf dynamic configs service. Use it for your projects or just disable dynamic config updates and keep developing without a supplementary service.

Full sources

See the full example: