Skip to content

Commit bd83d44

Browse files
joshuamiller01facebook-github-bot
authored andcommitted
RFC: fb_notify_merger: a custom resource to aggregate notifications and run them at
Summary: See README for context. Differential Revision: D70493988 fbshipit-source-id: c331efe49b43e6af21e6c6a03d7598fb7d9ec7d3
1 parent f38bb29 commit bd83d44

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-0
lines changed

cookbooks/fb_helpers/README.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,124 @@ fb_helpers_gated_template '/etc/foo.network' do
748748
end
749749
```
750750

751+
#### fb_notify_merger
752+
Use the `fb_notify_merger` resource to aggregate notifications and subscriptions
753+
into a single notification which fires in resource order. This solves the problem
754+
where, when multiple resources update that need to (for example) restart a service,
755+
one must currently choose between two flawed options. Either choose to
756+
notify `:immediately` (in which case multiple incorrect and unnecessary restarts
757+
occur), or `:delayed` (in which case the restarts are deduplicated, but happen
758+
out of order (at the end of the run), and create the opportunity for a (temporary)
759+
incorrectness).
760+
761+
Consider a common example. Two configuration files are inputs for a service,
762+
and updating them should cause the service to be restarted. In the initial setup
763+
scenario for a host, the service is not yet running, and we have the following
764+
recipe code:
765+
766+
```ruby
767+
template 'A' do
768+
...
769+
notifies :restart, 'service[X]', :delayed
770+
end
771+
772+
template 'B' do
773+
...
774+
notifies :restart, 'service[X]', :delayed
775+
end
776+
777+
service 'X' do
778+
action [:enable, :start]
779+
end
780+
```
781+
782+
The above code, during initial host setup, will trigger two delayed restarts, then
783+
the service will be started, then at the end of the run it will be restarted again
784+
(unnecessarily) due to the delayed notification.
785+
786+
`fb_notify_merger` serves as an in-order aggregation point for notifications, so
787+
that they can be deduplicated and delivered at the correct point in the run.
788+
789+
When updating the above code with the `fb_notify_merger`, we get:
790+
791+
```ruby
792+
template 'A' do
793+
...
794+
notifies :update, 'fb_notify_merger[C]', :immediately
795+
end
796+
797+
template 'B' do
798+
...
799+
notifies :update, 'fb_notify_merger[C]', :immediately
800+
end
801+
802+
fb_notify_merger 'C' do
803+
notifies :restart, 'service[X]', :immediately
804+
end
805+
806+
service 'X' do
807+
action [:enable, :start]
808+
end
809+
```
810+
811+
The above code has no unnecessary restart of the service at the end of the run.
812+
813+
The `fb_notify_merger` resource, when running the `:update` action, flips a bit
814+
to true, such that when the default `:merge` action runs, if the
815+
bit was true, the resource is marked as updated and triggers any associated
816+
notifications.
817+
818+
```ruby
819+
package 'syslog' do
820+
...
821+
notifies :update, 'fb_notify_merger[syslog]', :immediately
822+
end
823+
824+
template '/etc/sysconfig/syslog' do
825+
...
826+
notifies :update, 'fb_notify_merger[syslog]', :immediately
827+
end
828+
829+
fb_notify_merger 'syslog' do
830+
notifies :restart, "service[syslog]", :immediately
831+
end
832+
833+
service 'syslog' do
834+
action [:enable, :start]
835+
end
836+
```
837+
838+
In the sample above, even if both the `package` and the `template` update, only
839+
a single `:restart` is issued against the `service`.
840+
841+
Other examples:
842+
- Two or more input files are changed in the systemd configuration. Use the
843+
`fb_notify_merger` to aggregate the call to `systemctl daemon-reload` so it
844+
only happens once, and in order.
845+
- Two or more input files for a script are changed. Use the
846+
`fb_notify_merger` to aggregate the `:run` notification for the `execute`
847+
resource so it only happens once, and in order.
848+
849+
The `fb_notify_merger` resource order should be _immediately_ before the resource
850+
which it notifies to have the most correct behavior.
851+
852+
`notifies` and `subscribes` retain the same behavior as upstream chef and both
853+
work with `fb_notify_merger` (though `notifies` is preferred since it provides stronger
854+
guarantees).
855+
856+
You must use `:immediately` or `:before` when notifying the `fb_notify_merger`
857+
resource; if you use `:delayed` the in-order element is lost, and
858+
the entire point of using `fb_notify_merger` is lost.
859+
860+
Note that this is different from `notify_group`, which has no element of
861+
aggregating notifications, and only serves to minimize repetition in code.
862+
863+
Also note: `fb_notify_merger` tracks if it has already merged, and will
864+
fail the run if any subsequent `:update` or `:merge` actions are triggered.
865+
The `:merge` which triggers the actual aggregated notification should only happen
866+
a single time, immediately before the resource which is being notified; all
867+
other flows are fundamentally flawed.
868+
751869
### Reboot control
752870
If it's safe for Chef to reboot your host, set `reboot_allowed` to true in
753871
your cookbook:
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2+
#
3+
# Copyright (c) 2025-present, Facebook, Inc.
4+
# All rights reserved.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
# This recipe is only for running ChefSpec tests
19+
if defined?(ChefSpec)
20+
nm_resource = 'fb_notify_merger[ruby block 3]'
21+
22+
ruby_block 'some ruby block 1' do
23+
block {}
24+
notifies :update, nm_resource, :immediately
25+
end
26+
27+
ruby_block 'some ruby block 2' do
28+
block {}
29+
notifies :update, nm_resource, :immediately
30+
end
31+
32+
fb_notify_merger 'ruby block 3' do
33+
notifies :run, 'ruby_block[some ruby block 3]', :immediately
34+
end
35+
36+
ruby_block 'some ruby block 3' do
37+
block {}
38+
action :nothing
39+
end
40+
41+
ruby_block 'late ruby block' do
42+
only_if { node['guard_update'] }
43+
block {}
44+
notifies :update, nm_resource, :immediately
45+
end
46+
47+
ruby_block 'later ruby block' do
48+
only_if { node['guard_merge'] }
49+
block {}
50+
notifies :merge, nm_resource, :immediately
51+
end
52+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2+
#
3+
# Copyright (c) 2025-present, Facebook, Inc.
4+
# All rights reserved.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
provides :fb_notify_merger
19+
default_action :merge
20+
property :_update, :is => [TrueClass, FalseClass], :default => false, :required => false
21+
property :_merged, :is => [TrueClass, FalseClass], :default => false, :required => false
22+
23+
action :update do
24+
if new_resource._merged
25+
fail 'update was called against an already-merged notify_merger!'
26+
end
27+
new_resource._update = true
28+
end
29+
30+
action :merge do
31+
if new_resource._merged
32+
fail 'merge was called against an already-merged notify_merger!'
33+
end
34+
35+
if new_resource._update
36+
converge_by("merging notifications for #{new_resource}") do
37+
new_resource._merged = true
38+
end
39+
end
40+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2
2+
#
3+
# Copyright (c) 2025-present, Facebook, Inc.
4+
# All rights reserved.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
require './spec/spec_helper'
19+
20+
recipe 'fb_helpers::notify_merger_spec', :unsupported => [:mac_os_x] do |tc|
21+
nm_resource = 'ruby block 3'
22+
23+
it 'should trigger notifications when the fb_notify_merger resource is updated' do
24+
chef_run = tc.chef_run(
25+
:step_into => ['fb_notify_merger', 'ruby_block'],
26+
)
27+
28+
chef_run.converge(described_recipe)
29+
30+
expect(chef_run.fb_notify_merger(nm_resource)).to be_updated
31+
expect(chef_run.fb_notify_merger(nm_resource)._update).to be true
32+
expect(chef_run.ruby_block('some ruby block 3')).to be_updated
33+
end
34+
35+
it 'should not trigger notifications when the fb_notify_merger resource is not updated' do
36+
chef_run = tc.chef_run(
37+
:step_into => ['fb_notify_merger'],
38+
)
39+
40+
chef_run.converge(described_recipe)
41+
42+
expect(chef_run.fb_notify_merger(nm_resource)).not_to be_updated
43+
expect(chef_run.fb_notify_merger(nm_resource)._update).to be false
44+
expect(chef_run.ruby_block('some ruby block 3')).not_to be_updated
45+
end
46+
47+
it 'should fail if update is called out of order' do
48+
chef_run = tc.chef_run(
49+
:step_into => ['fb_notify_merger', 'ruby_block'],
50+
) do |node|
51+
node.default['guard_update'] = true
52+
end
53+
54+
expect do
55+
chef_run.converge(described_recipe)
56+
end.to raise_error(RuntimeError, /update was called against an already-merged notify_merger!/)
57+
end
58+
59+
it 'should fail if merge is called out of order' do
60+
chef_run = tc.chef_run(
61+
:step_into => ['fb_notify_merger', 'ruby_block'],
62+
) do |node|
63+
node.default['guard_merge'] = true
64+
end
65+
66+
expect do
67+
chef_run.converge(described_recipe)
68+
end.to raise_error(RuntimeError, /merge was called against an already-merged notify_merger!/)
69+
end
70+
end

0 commit comments

Comments
 (0)