Skip to content

Commit be056bd

Browse files
authored
Recap23 talk prep (#17)
* some more variance in the example-cap-server responses * infos about local dependencies * can't think of a better naming for now * gem dependency update * doc overview page wording * another polish pass on usage docs * some basic infos about scopes * start of architecture docs * start of architecture docs 2 * start of architecture scopes drawio * more work on scoping * more work on scoping 2
1 parent 3251fa8 commit be056bd

File tree

11 files changed

+150
-48
lines changed

11 files changed

+150
-48
lines changed

docs/Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ source "https://rubygems.org"
55
gem "github-pages", "~> 228", group: :jekyll_plugins
66

77
# https://github.com/just-the-docs/just-the-docs/releases
8-
gem "just-the-docs", "~> 0.4.0"
8+
gem "just-the-docs", "~> 0.4.2"

docs/Gemfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
GEM
22
remote: https://rubygems.org/
33
specs:
4-
activesupport (6.1.7.3)
4+
activesupport (6.1.7.4)
55
concurrent-ruby (~> 1.0, >= 1.0.2)
66
i18n (>= 1.6, < 2)
77
minitest (>= 5.1)
@@ -25,7 +25,7 @@ GEM
2525
ffi (>= 1.15.0)
2626
eventmachine (1.2.7)
2727
execjs (2.8.1)
28-
faraday (2.7.6)
28+
faraday (2.7.9)
2929
faraday-net_http (>= 2.0, < 3.1)
3030
ruby2_keywords (>= 0.0.4)
3131
faraday-net_http (3.0.2)
@@ -215,7 +215,7 @@ GEM
215215
jekyll (>= 3.5, < 5.0)
216216
jekyll-feed (~> 0.9)
217217
jekyll-seo-tag (~> 2.1)
218-
minitest (5.18.0)
218+
minitest (5.18.1)
219219
nokogiri (1.13.10)
220220
mini_portile2 (~> 2.8.0)
221221
racc (~> 1.4)
@@ -262,7 +262,7 @@ PLATFORMS
262262

263263
DEPENDENCIES
264264
github-pages (~> 228)
265-
just-the-docs (~> 0.4.0)
265+
just-the-docs (~> 0.4.2)
266266

267267
BUNDLED WITH
268268
1.17.2
40.9 KB
Loading

docs/architecture/index.md

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,21 @@ nav_order: 3
1515

1616
## Initialization
1717

18-
During initialization the Feature Toggles want to synchronize with the central state or lazyly create it based on the
19-
fallback values if necessary. Another goal is to make sure that _current_ validation rules are respected in all cases
20-
and, for example, retroactively applied to the central state.
18+
During initialization the Feature Toggles want to synchronize with the central state. Another goal is to make sure
19+
that _current_ validation rules are respected in all cases and retroactively applied to the central state.
2120

2221
Initialization broadly has this workflow:
2322

24-
- read the configuration
23+
- read and process the configuration
2524
- validate fallback values and warn about invalid fallback values
2625
- if Redis cannot be reached:
2726
- use fallback values as local state and stop
2827
- if Redis is reachable:
2928
- read state and filter out values inconsistent with validation rules
30-
- publish those validated fallback values, where corresponding keys are missing from state
3129
- use validated Redis values if possible or, if none exist, fallback values as local state
3230
- subscribe to future updates from Redis
3331

34-
After intialization, usage code can rely on always getting at least the fallback values (including invalid values) or,
32+
After initialization, usage code can rely on always getting at least the fallback values (including invalid values) or,
3533
if possible, validated values from Redis.
3634

3735
## Single Key Approach
@@ -40,26 +38,52 @@ if possible, validated values from Redis.
4038
| :-------------------------------------: |
4139
| _Single Key Architecture_ |
4240

43-
The current implementation uses a single Redis key to store all the state for one unique name, which is usually
44-
associated with a single app, though the Feature Toggles support the case where multiple apps _with the same
45-
configuration_ use the same unique name. In the diagram you can see both examples, app 1 has a partner app, that uses
46-
the same unique key and all instances of both apps, will synchronize with Redis. On the other hand app 2 is alone,
47-
which is the most common use-case.
41+
The current implementation uses a single Redis key of type `hash` to store the state of all toggles for one unique
42+
name. A unique name is usually associated with a single app, but the library also supports the case where multiple apps
43+
_with the same configuration_ use the same unique name.
4844

49-
Using a single key on Redis for the state of all toggles has some implementation advantages:
45+
In the diagram you can see both examples, app 1 has a partner app, that uses the same unique key and all instances of
46+
both apps, will synchronize with Redis. On the other hand app 2 is alone, which is the most common use-case.
5047

51-
- discovery about which toggles are maintained is trivial, no namespacing is needed
52-
- pub/sub change-detection, synchronization, and lock-resolution are also easier
48+
## Scoping
5349

54-
On the other hand, there is also a disadvantage. The sync speed will degrade with the cumulative size of all toggle
55-
states. In practice, if you have lots of toggles with long state strings, it will be slower than necessary.
50+
In their easiest use-cases, the Feature Toggles describe server-level state, which is _independent_ of any runtime
51+
context. Meaning the feature toggle's value will be the same for any request, any tenant, any user, any code component,
52+
or any other abstraction layer. In practice this is often insufficient.
5653

57-
## Request-Level Toggles
54+
Scoping is our concept to allow discriminating the feature toggle values based on runtime context information.
55+
Let's take a very common example, where both `user` and `tenant` scopes are used.
5856

59-
The Feature Toggles are currently implemented with server-level state. They have the limitation that their runtime
60-
values _cannot_ be different based on attributes of individual requests, for example, which tenant is making the
61-
request.
57+
| ![](architecture-scopes.png) |
58+
| :-------------------------------------------: |
59+
| _User and tenant scopes for a feature toggle_ |
6260

63-
This kind of logic can be implemented outside the Feature Toggles though. You can use a string-type toggle and encode
64-
the relevant states for all tenants, or other discriminating request attributes. During the request processing, you can
65-
get the toggle's state for all tenants and act based on the one making the request.
61+
To realize the distinction, runtime scope information is passed to the library as a `Map<string, string>`, which results
62+
in a corresponding value check order of _descending specificity_, e.g.:
63+
64+
- `getFeatureValue(key)`
65+
- root scope, fallback
66+
- `getFeatureValue(key, { tenant: cds.context.tenant })`
67+
- `tenant` scope, root scope, fallback
68+
- `getFeatureValue(key, { user: cds.context.user.id, tenant: cds.context.tenant })`
69+
- `user+tenant` scope, `user` scope, `tenant` scope, root scope, fallback
70+
- `getFeatureValue(key, { tenant: cds.context.tenant, user: cds.context.user.id })`
71+
- `user+tenant` scope, `tenant` scope, `user` scope, root scope, fallback
72+
73+
The root scope is always the least specific or broadest scope and corresponds to _not_ specifying any particular scope
74+
information. Now, the framework will go through these potential values in this order and check if any of them have been
75+
set. The first value that has been set stops the chain and is returned to the caller.
76+
77+
With this setup, we can change the resulting value for anyone with tenant `t1`, _and no other, more specific scopes_,
78+
by using
79+
80+
- `changeFeatureValue(key, "new value for t1", { tenant: "t1" })`
81+
82+
And we could change the behavior again with for the more specific tenant `t1` and user `john`, by using
83+
84+
- `changeFeatureValue(key, "new value just for john within t1", { user: "john", tenant: "t1" })`
85+
86+
{: .warn}
87+
As we can see in the precedence check order, if we had just set `changeFeatureValue(key, "new value for john", { user: "john" })`,
88+
then it depends on the order used in the `getFeatureValue` call, whether the `user` scope is evaluated before
89+
the `tenant` scope.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<mxfile host="app.diagrams.net" modified="2023-07-06T13:28:06.349Z" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5.1 Safari/605.1.15" etag="XOApslF1hlDBLMf0fhT_" version="21.5.2" type="device">
2+
<diagram name="Page-1" id="IgljjqXF11xHlQnlV1vm">
3+
<mxGraphModel dx="949" dy="1162" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
4+
<root>
5+
<mxCell id="0" />
6+
<mxCell id="1" parent="0" />
7+
<mxCell id="sAZw095eo5NpUbxJhi_2-9" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=default;" parent="1" vertex="1">
8+
<mxGeometry x="160" y="40" width="640" height="440" as="geometry" />
9+
</mxCell>
10+
<mxCell id="sAZw095eo5NpUbxJhi_2-1" value="" style="ellipse;whiteSpace=wrap;html=1;" parent="1" vertex="1">
11+
<mxGeometry x="190" y="120" width="580" height="320" as="geometry" />
12+
</mxCell>
13+
<mxCell id="sAZw095eo5NpUbxJhi_2-2" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=none;" parent="1" vertex="1">
14+
<mxGeometry x="230" y="187.5" width="350" height="185" as="geometry" />
15+
</mxCell>
16+
<mxCell id="sAZw095eo5NpUbxJhi_2-4" value="" style="ellipse;whiteSpace=wrap;html=1;fillColor=none;" parent="1" vertex="1">
17+
<mxGeometry x="380" y="187.5" width="350" height="185" as="geometry" />
18+
</mxCell>
19+
<mxCell id="sAZw095eo5NpUbxJhi_2-5" value="Root Scope" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
20+
<mxGeometry x="450" y="130" width="60" height="30" as="geometry" />
21+
</mxCell>
22+
<mxCell id="sAZw095eo5NpUbxJhi_2-6" value="user&lt;br&gt;Scope" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
23+
<mxGeometry x="250" y="260" width="60" height="30" as="geometry" />
24+
</mxCell>
25+
<mxCell id="sAZw095eo5NpUbxJhi_2-10" value="Fallback" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
26+
<mxGeometry x="450" y="50" width="60" height="30" as="geometry" />
27+
</mxCell>
28+
<mxCell id="sAZw095eo5NpUbxJhi_2-11" value="tenant&lt;br&gt;Scope" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
29+
<mxGeometry x="640" y="260" width="60" height="30" as="geometry" />
30+
</mxCell>
31+
<mxCell id="sAZw095eo5NpUbxJhi_2-12" value="user+tenant&lt;br&gt;Scope" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
32+
<mxGeometry x="450" y="260" width="60" height="30" as="geometry" />
33+
</mxCell>
34+
<mxCell id="ghchXokQqMajBl4t-yi9-1" value="Local configuration" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
35+
<mxGeometry x="20" y="50" width="60" height="30" as="geometry" />
36+
</mxCell>
37+
<mxCell id="ghchXokQqMajBl4t-yi9-2" value="Synchronized via Redis" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
38+
<mxGeometry x="20" y="120" width="60" height="30" as="geometry" />
39+
</mxCell>
40+
<mxCell id="ghchXokQqMajBl4t-yi9-3" value="" style="endArrow=none;dashed=1;html=1;rounded=0;strokeWidth=3;" edge="1" parent="1">
41+
<mxGeometry width="50" height="50" relative="1" as="geometry">
42+
<mxPoint x="10" y="100" as="sourcePoint" />
43+
<mxPoint x="820" y="100" as="targetPoint" />
44+
</mxGeometry>
45+
</mxCell>
46+
</root>
47+
</mxGraphModel>
48+
</diagram>
49+
</mxfile>

docs/index.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
layout: home
3-
title: Home
3+
title: Overview
44
nav_order: 1
55
---
66

@@ -17,14 +17,14 @@ npm install --save @cap-js-community/feature-toggle-library
1717
## Features
1818

1919
- maintain feature toggle states consistently across multiple app instances
20-
- feature toggle changes are published from Redis to subscribed app instance with publish/subscribe pattern [PUB/SUB](https://redis.io/topics/pubsub)
20+
- feature toggle changes are published from Redis to subscribed app instances with publish/subscribe pattern [PUB/SUB](https://redis.io/topics/pubsub)
2121
- horizontal app scaling is supported and new app instances will start with the correct state, or fallback values, if they cannot connect to Redis
22-
- feature toggles can be changed only for accesses with specific scopes, i.e, for specific tenants
22+
- feature toggle values can be changed specifically for accessors with certain scopes, e.g., for specific tenants, users,...
2323
- users can register change handler callbacks for specific toggles
2424
- users can register custom input validation callbacks for specific toggles
2525

2626
## Further topics
2727

2828
- Configuration and code snippets: [Usage](usage)
2929
- Architecture and related concepts: [Architecture](architecture)
30-
- CAP server example: [CAP Example](https://github.com/cap-js-community/feature-toggle-library/blob/main/example-cap-server)
30+
- Example CAP server: [CAP Example](https://github.com/cap-js-community/feature-toggle-library/blob/main/example-cap-server)

docs/usage/index.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const { singleton } = require("@cap-js-community/feature-toggle-library");
3434

3535
{: .warn }
3636
Be aware that using the singleton instance and changing the app name will invalidate the Redis state and set
37-
back all toggles to their fallback.
37+
back all toggles to their fallback values.
3838

3939
## Configuration
4040

@@ -114,9 +114,10 @@ The semantics of these properties are as follows.
114114
| fallbackValue | true | see below |
115115
| appUrl | | see below |
116116
| validation | | regex for input validation |
117+
| allowedScopes | | see below |
117118

118119
_fallbackValue_<br>
119-
This value gets set initially when the featue toggle is introduced, and it is also used as a fallback when
120+
This value gets set initially when the feature toggle is introduced, and it is also used as a fallback when
120121
communication with Redis is blocked during startup.
121122

122123
_appUrl_<br>
@@ -126,6 +127,11 @@ Regex for activating feature toggle _only_ if the cf app's url matches
126127
- for EU10 landscape `\.cfapps\.eu10\.hana\.ondemand\.com$`
127128
- specific CANARY app `<cf-app-name>\.cfapps\.sap\.hana\.ondemand\.com$`
128129

130+
_allowedScopes_<br>
131+
This is an additional form of change validation. AllowedScopes can be set to a list of strings, for example
132+
`allowedScopes: [tenant, user]`. With this configuration only matching scopes can be used when setting feature toggle
133+
values.
134+
129135
{: .info }
130136
You can use the type `string` to encode more complex data types, like arrays or objects, but need to take care of the
131137
serialization/deserialization yourself. In these cases, make sure to use [external validation](#external-validation)
@@ -161,6 +167,12 @@ const {
161167
162168
// ... in some function
163169
const logLevel = getFeatureValue("/srv/util/logger/logLevel");
170+
171+
// ... with runtime scope information
172+
const logLevel = getFeatureValue("/srv/util/logger/logLevel", {
173+
tenant: cds.context.tenant,
174+
user: cds.context.user.id,
175+
});
164176
```
165177

166178
{: .warn }
@@ -170,20 +182,20 @@ top-level.
170182

171183
### Observing Feature Value Changes
172184

173-
You can register for all updates of a specific feature toggle:
185+
You can register a callback for all updates to a feature toggle:
174186

175187
```javascript
176188
const {
177189
singleton: { registerFeatureValueChangeHandler },
178190
} = require("@cap-js-community/feature-toggle-library");
179191
180-
registerFeatureValueChangeHandler("/srv/util/logger/logLevel", (newValue, oldValue) => {
181-
console.log("changing log level from %s to %s", oldValue, newValue);
192+
registerFeatureValueChangeHandler("/srv/util/logger/logLevel", (newValue, oldValue, scopeMap) => {
193+
console.log("changing log level from %s to %s (scope %j)", oldValue, newValue, scopeMap);
182194
updateLogLevel(newValue);
183195
});
184196
185197
// ... or for async APIs
186-
registerFeatureValueChangeHandler("/srv/util/logger/logLevel", async (newValue) => {
198+
registerFeatureValueChangeHandler("/srv/util/logger/logLevel", async (newValue, oldValue, scopeMap) => {
187199
await updateLogLevel(newValue);
188200
});
189201
```
@@ -200,8 +212,9 @@ const {
200212
singleton: { changeFeatureValue },
201213
} = require("@cap-js-community/feature-toggle-library");
202214
203-
async function changeIt(newValue) {
204-
const validationErrors = await changeFeatureValue("/srv/util/logger/logLevel", newValue);
215+
// optionally pass in a scopeMap, which describes the least specific scope where the change should happen
216+
async function changeIt(newValue, scopeMap) {
217+
const validationErrors = await changeFeatureValue("/srv/util/logger/logLevel", newValue, scopeMap);
205218
if (Array.isArray(validationErrors) && validationErrors.length > 0) {
206219
for (const { errorMessage, errorMessageValues } of validationErrors) {
207220
// show errors to the user, the change did not happen
@@ -214,7 +227,8 @@ The change API `changeFeatureValue` will return when the change is published to
214227
processing delay until the change is picked up by all subscribers.
215228

216229
{: .info }
217-
Setting a feature value to `null` will delete the associated remote state and effectively reset it to its fallback value.
230+
Setting a feature value to `null` will delete the associated remote state and effectively reset it to its fallback
231+
value.
218232

219233
### External Validation
220234

example-cap-server/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
# Example CAP Server
2+
3+
## Node and local dependencies
4+
5+
In [package.json](./package.json), we declare our library as a local `file:` dependency. There is a bug in node where
6+
such local dependencies cause imports _from the local dependency's code_ to fail, because of the way symlinks are
7+
handled. For details see https://github.com/nodejs/node/issues/3402.
8+
9+
To fix this behavior, you need to run `node` with the option `--preserve-symlinks`.

example-cap-server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"start": "cds-serve"
77
},
88
"dependencies": {
9-
"@cap-js-community/feature-toggle-library": "file:../",
9+
"@cap-js-community/feature-toggle-library": "file:..",
1010
"@sap/cds": "^7.0.0",
1111
"express": "^4.18.2"
1212
}

example-cap-server/srv/handler/check-service.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,23 @@ const {
66

77
const { FEATURE } = require("../feature");
88

9-
const BAD_REQUEST_ERROR_HTTP_CODE = 400;
9+
const LOW_VALUE_RESPONSES = ["hello", "barely made it"];
1010

11-
const SUCCESS_RESPONSES = ["well done", "works", "success", "huzzah", "celebrations"];
11+
const MEDIUM_VALUE_RESPONSES = ["welcome", "take a seat", "step right up"];
12+
const MEDIUM_BOUNDARY = 10;
13+
14+
const HIGH_VALUE_RESPONSES = ["well done", "full success", "huzzah", "celebrations"];
15+
const HIGH_BOUNDARY = 100;
1216

1317
const priorityHandler = async (context) => {
1418
const value = getFeatureValue(FEATURE.CHECK_API_PRIORITY, { user: context.user.id, tenant: context.tenant });
15-
return value > 0
16-
? context.reply(value + " | " + SUCCESS_RESPONSES[Math.floor(Math.random() * SUCCESS_RESPONSES.length)])
17-
: context.error(BAD_REQUEST_ERROR_HTTP_CODE);
19+
const messages =
20+
value >= HIGH_BOUNDARY
21+
? HIGH_VALUE_RESPONSES
22+
: value >= MEDIUM_BOUNDARY
23+
? MEDIUM_VALUE_RESPONSES
24+
: LOW_VALUE_RESPONSES;
25+
return context.reply(value + " | " + messages[Math.floor(Math.random() * messages.length)]);
1826
};
1927

2028
module.exports = async (srv) => {

0 commit comments

Comments
 (0)