inboxable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +131 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +142 -0
- data/Rakefile +12 -0
- data/inboxable.gemspec +35 -0
- data/lib/generators/inboxable/handler_generator.rb +74 -0
- data/lib/generators/inboxable/install_generator.rb +49 -0
- data/lib/generators/inboxable/processor_generator.rb +49 -0
- data/lib/inboxable/configuration.rb +27 -0
- data/lib/inboxable/polling_receiver_worker.rb +35 -0
- data/lib/inboxable/version.rb +5 -0
- data/lib/inboxable.rb +18 -0
- data/lib/templates/activerecord_initializer.rb +3 -0
- data/lib/templates/activerecrod_inbox.rb +30 -0
- data/lib/templates/create_inboxable_inboxes.rb +20 -0
- data/lib/templates/mongoid_inbox.rb +46 -0
- data/lib/templates/mongoid_initializer.rb +3 -0
- data/sig/inboxable/configuration.rbs +10 -0
- data/sig/inboxable/polling_receiver_worker.rbs +7 -0
- data/sig/inboxable.rbs +5 -0
- metadata +73 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1207d628727126924ff331738cd526e2dc676964fae984d2b53c6cf97a664418
|
4
|
+
data.tar.gz: 7163397c6d3bd6c90e3beca17957943dd38866d677b4c4057ddf1a14bcff9f33
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a0e65af5f85b964269d7e20525754ec0ebe8152ea9e3d946480af9ad29c973756a70bbd97a4034d11eb38bc97b777e618b58daae4afc19825c99c00ec21c3e3a
|
7
|
+
data.tar.gz: b9da958286397e8b9fc7f165af001a5a234d4e582cf971b6fc6f21f29574078928cb12c6d6b52c99e5187c3867a540af0aca4be82e942c675c910217e8545b9f
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
require: rubocop-rails
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
NewCops: enable
|
5
|
+
SuggestExtensions: false
|
6
|
+
Layout/SpaceBeforeBrackets: # (new in 1.7)
|
7
|
+
Enabled: true
|
8
|
+
Layout/LineLength:
|
9
|
+
Max: 350
|
10
|
+
Lint/AmbiguousAssignment: # (new in 1.7)
|
11
|
+
Enabled: true
|
12
|
+
Lint/DeprecatedConstants: # (new in 1.8)
|
13
|
+
Enabled: true
|
14
|
+
Lint/DuplicateBranch: # (new in 1.3)
|
15
|
+
Enabled: true
|
16
|
+
Lint/DuplicateRegexpCharacterClassElement: # (new in 1.1)
|
17
|
+
Enabled: true
|
18
|
+
Lint/EmptyBlock: # (new in 1.1)
|
19
|
+
Enabled: true
|
20
|
+
Lint/EmptyClass: # (new in 1.3)
|
21
|
+
Enabled: true
|
22
|
+
Lint/LambdaWithoutLiteralBlock: # (new in 1.8)
|
23
|
+
Enabled: true
|
24
|
+
Lint/NoReturnInBeginEndBlocks: # (new in 1.2)
|
25
|
+
Enabled: true
|
26
|
+
Lint/NumberedParameterAssignment: # (new in 1.9)
|
27
|
+
Enabled: true
|
28
|
+
Lint/OrAssignmentToConstant: # (new in 1.9)
|
29
|
+
Enabled: true
|
30
|
+
Lint/RedundantDirGlobSort: # (new in 1.8)
|
31
|
+
Enabled: true
|
32
|
+
Lint/SymbolConversion: # (new in 1.9)
|
33
|
+
Enabled: true
|
34
|
+
Lint/ToEnumArguments: # (new in 1.1)
|
35
|
+
Enabled: true
|
36
|
+
Lint/TripleQuotes: # (new in 1.9)
|
37
|
+
Enabled: true
|
38
|
+
Lint/UnexpectedBlockArity: # (new in 1.5)
|
39
|
+
Enabled: true
|
40
|
+
Lint/UnmodifiedReduceAccumulator: # (new in 1.1)
|
41
|
+
Enabled: true
|
42
|
+
Style/ArgumentsForwarding: # (new in 1.1)
|
43
|
+
Enabled: true
|
44
|
+
Style/CollectionCompact: # (new in 1.2)
|
45
|
+
Enabled: true
|
46
|
+
Style/DocumentDynamicEvalDefinition: # (new in 1.1)
|
47
|
+
Enabled: true
|
48
|
+
Style/Documentation:
|
49
|
+
Enabled: false
|
50
|
+
Style/FrozenStringLiteralComment:
|
51
|
+
Enabled: false
|
52
|
+
Style/EndlessMethod: # (new in 1.8)
|
53
|
+
Enabled: true
|
54
|
+
Style/HashConversion: # (new in 1.10)
|
55
|
+
Enabled: true
|
56
|
+
Style/HashExcept: # (new in 1.7)
|
57
|
+
Enabled: true
|
58
|
+
Style/IfWithBooleanLiteralBranches: # (new in 1.9)
|
59
|
+
Enabled: true
|
60
|
+
Style/NegatedIfElseCondition: # (new in 1.2)
|
61
|
+
Enabled: true
|
62
|
+
Style/NilLambda: # (new in 1.3)
|
63
|
+
Enabled: true
|
64
|
+
Style/RedundantArgument: # (new in 1.4)
|
65
|
+
Enabled: true
|
66
|
+
Style/SwapValues: # (new in 1.1)
|
67
|
+
Enabled: true
|
68
|
+
Rails/ActiveRecordCallbacksOrder: # (new in 2.7)
|
69
|
+
Enabled: true
|
70
|
+
Rails/AfterCommitOverride: # (new in 2.8)
|
71
|
+
Enabled: true
|
72
|
+
Rails/AttributeDefaultBlockValue: # (new in 2.9)
|
73
|
+
Enabled: true
|
74
|
+
Rails/FindById: # (new in 2.7)
|
75
|
+
Enabled: true
|
76
|
+
Rails/Inquiry: # (new in 2.7)
|
77
|
+
Enabled: true
|
78
|
+
Rails/MailerName: # (new in 2.7)
|
79
|
+
Enabled: true
|
80
|
+
Rails/MatchRoute: # (new in 2.7)
|
81
|
+
Enabled: true
|
82
|
+
Rails/NegateInclude: # (new in 2.7)
|
83
|
+
Enabled: true
|
84
|
+
Rails/Pluck: # (new in 2.7)
|
85
|
+
Enabled: true
|
86
|
+
Rails/PluckInWhere: # (new in 2.7)
|
87
|
+
Enabled: true
|
88
|
+
Rails/RenderInline: # (new in 2.7)
|
89
|
+
Enabled: true
|
90
|
+
Rails/RenderPlainText: # (new in 2.7)
|
91
|
+
Enabled: true
|
92
|
+
Rails/ShortI18n: # (new in 2.7)
|
93
|
+
Enabled: true
|
94
|
+
Rails/SquishedSQLHeredocs: # (new in 2.8)
|
95
|
+
Enabled: true
|
96
|
+
Rails/UniqueValidationWithoutIndex:
|
97
|
+
Enabled: false
|
98
|
+
Rails/WhereEquals: # (new in 2.9)
|
99
|
+
Enabled: true
|
100
|
+
Rails/WhereExists: # (new in 2.7)
|
101
|
+
Enabled: true
|
102
|
+
Rails/WhereNot: # (new in 2.8)
|
103
|
+
Enabled: true
|
104
|
+
Metrics/BlockLength:
|
105
|
+
Enabled: false
|
106
|
+
Metrics/AbcSize:
|
107
|
+
Enabled: false
|
108
|
+
Metrics/MethodLength:
|
109
|
+
Enabled: false
|
110
|
+
Metrics/CyclomaticComplexity:
|
111
|
+
Enabled: false
|
112
|
+
Metrics/PerceivedComplexity:
|
113
|
+
Enabled: false
|
114
|
+
Lint/DuplicateMethods: # Disables duplicate methods warning
|
115
|
+
Enabled: false
|
116
|
+
Gemspec/RequiredRubyVersion: # Disables required ruby version warning
|
117
|
+
Enabled: false
|
118
|
+
Metrics/ParameterLists: # Disables parameter lists warning
|
119
|
+
Enabled: false
|
120
|
+
Lint/NextWithoutAccumulator: # Disables next without accumulator warning
|
121
|
+
Enabled: false
|
122
|
+
Lint/ShadowingOuterLocalVariable: # Disables shadowing outer local variable warning
|
123
|
+
Enabled: false
|
124
|
+
Metrics/ModuleLength: # Disables module length warning
|
125
|
+
Enabled: false
|
126
|
+
Layout/EmptyLinesAroundClassBody: # Disables empty lines around class body warning
|
127
|
+
Enabled: false
|
128
|
+
Layout/HeredocIndentation: # Disables heredoc indentation warning
|
129
|
+
Enabled: false
|
130
|
+
Layout/ClosingHeredocIndentation: # Disables closing heredoc indentation warning
|
131
|
+
Enabled: false
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6
|
+
|
7
|
+
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
8
|
+
|
9
|
+
## Our Standards
|
10
|
+
|
11
|
+
Examples of behavior that contributes to a positive environment for our community include:
|
12
|
+
|
13
|
+
* Demonstrating empathy and kindness toward other people
|
14
|
+
* Being respectful of differing opinions, viewpoints, and experiences
|
15
|
+
* Giving and gracefully accepting constructive feedback
|
16
|
+
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
17
|
+
* Focusing on what is best not just for us as individuals, but for the overall community
|
18
|
+
|
19
|
+
Examples of unacceptable behavior include:
|
20
|
+
|
21
|
+
* The use of sexualized language or imagery, and sexual attention or
|
22
|
+
advances of any kind
|
23
|
+
* Trolling, insulting or derogatory comments, and personal or political attacks
|
24
|
+
* Public or private harassment
|
25
|
+
* Publishing others' private information, such as a physical or email
|
26
|
+
address, without their explicit permission
|
27
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
28
|
+
professional setting
|
29
|
+
|
30
|
+
## Enforcement Responsibilities
|
31
|
+
|
32
|
+
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
33
|
+
|
34
|
+
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
35
|
+
|
36
|
+
## Scope
|
37
|
+
|
38
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
39
|
+
|
40
|
+
## Enforcement
|
41
|
+
|
42
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hama127n@gmail.com. All complaints will be reviewed and investigated promptly and fairly.
|
43
|
+
|
44
|
+
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
45
|
+
|
46
|
+
## Enforcement Guidelines
|
47
|
+
|
48
|
+
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
49
|
+
|
50
|
+
### 1. Correction
|
51
|
+
|
52
|
+
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
53
|
+
|
54
|
+
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
55
|
+
|
56
|
+
### 2. Warning
|
57
|
+
|
58
|
+
**Community Impact**: A violation through a single incident or series of actions.
|
59
|
+
|
60
|
+
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
61
|
+
|
62
|
+
### 3. Temporary Ban
|
63
|
+
|
64
|
+
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
65
|
+
|
66
|
+
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
67
|
+
|
68
|
+
### 4. Permanent Ban
|
69
|
+
|
70
|
+
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
71
|
+
|
72
|
+
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
73
|
+
|
74
|
+
## Attribution
|
75
|
+
|
76
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
|
77
|
+
available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
78
|
+
|
79
|
+
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
80
|
+
|
81
|
+
[homepage]: https://www.contributor-covenant.org
|
82
|
+
|
83
|
+
For answers to common questions about this code of conduct, see the FAQ at
|
84
|
+
https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Muhammad Nawzad
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
# Inboxable
|
2
|
+
|
3
|
+
The Inboxable gem is an opinionated gem that implements the **Transactional Inbox** pattern for Rails applications. The gem provides support for both ActiveRecord and Mongoid. If you are using the **Transactional Outbox** pattern in your application, you can use the [Outboxable](https://github.com/ditkrg/outboxable) to handle both ends of the pattern.
|
4
|
+
|
5
|
+
Please take into consideration that this Gem is **opinionated**, meaning it expects you to follow a certain pattern and specific setting. If you don't like it, you can always fork it and change it.
|
6
|
+
|
7
|
+
_**Note:**_ If you are not familiar with the **Transactional Outbox and Inbox** patterns, please read the following article [Microservices 101: Transactional Outbox and Inbox](https://softwaremill.com/microservices-101/) before proceeding.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'inboxable'
|
15
|
+
```
|
16
|
+
|
17
|
+
And then execute:
|
18
|
+
|
19
|
+
bundle install
|
20
|
+
|
21
|
+
Or install it yourself as:
|
22
|
+
|
23
|
+
gem install inboxable
|
24
|
+
|
25
|
+
## How It Works
|
26
|
+
|
27
|
+
The inboxable gem uses a cron job to poll the inbox for new messages. The cron job is scheduled to run every 5 seconds by default. The cron job will fetch the messages from the inbox and process them in batches. The number of messages to be processed in each batch is 100 by default. In the event of a failure, the message will be retried up to 3 times with a delay of 5 seconds between each retry. If the message is still not processed after 3 attempts, the message will be moved to the dead letter queue. Note that all the previous values can be configured using the configuration options. (See the [Configuration](#configuration) section below for more information.)
|
28
|
+
|
29
|
+
## Usage
|
30
|
+
|
31
|
+
The installation command above will install the Inboxable gem and its dependencies. However, in order for Inboxable to work, you must set up your application to use Inboxable gem to process the inboxes. The following sections will show you how to set up your application to use Inboxable gem.
|
32
|
+
|
33
|
+
The below command is to initialize the gem and generate the configuration file.
|
34
|
+
|
35
|
+
```sh
|
36
|
+
rails g inboxable:install --orm <orm>
|
37
|
+
```
|
38
|
+
|
39
|
+
The gem provides support for both ActiveRecord and Mongoid. The `--orm` option is used to specify the ORM that you are using in your application. The `--orm` option can be either `active_record` or `mongoid`. Here is an example of how to generate the configuration file for an application that uses ActiveRecord.
|
40
|
+
|
41
|
+
```sh
|
42
|
+
rails g inboxable:install --orm active_record
|
43
|
+
```
|
44
|
+
|
45
|
+
The above command will generate the following files:
|
46
|
+
|
47
|
+
1. `config/initializers/inboxable.rb`: This file contains the configuration for the gem. (See the [Configuration](#configuration) section below for more information.)
|
48
|
+
2. `app/models/inbox.rb`: This file contains the `Inbox` model. This model is used to store the messages in the inbox. (See the [Inbox Model](#inbox-model) section below for more information.)
|
49
|
+
3. If you are using ActiveRecord, the following migration file will be generated:
|
50
|
+
- `db/migrate/<timestamp>_create_inboxable_inboxes.rb`: This migration file is used to create the `inboxes` table in the database. (See the [Inbox Model](#inbox-model) section below for more information.)
|
51
|
+
|
52
|
+
### Generating Handler Files & Processors
|
53
|
+
|
54
|
+
The Inboxable gem provides generators that can be used to generate event handler files and processors. Here is how to generate the files.
|
55
|
+
|
56
|
+
#### Generating Handler Files
|
57
|
+
|
58
|
+
The following command is used to generate the handler files.
|
59
|
+
|
60
|
+
```sh
|
61
|
+
rails g inboxable:handler --handler_name <handler_name> --namespace <namespace>
|
62
|
+
```
|
63
|
+
|
64
|
+
The options are as follows:
|
65
|
+
|
66
|
+
- `handler_name`: This option is used to specify the name of the handler. An example of a handler name is `user_update_handler`.
|
67
|
+
- `namespace`: This option is used to specify the namespace for the handler class. The namespace must be in the format `<namespace1>::<namespace2>`. For example, if the namespace is `Common::UsersApi`, the handler file will be generated in the `app/handlers/common/users_api` directory.
|
68
|
+
|
69
|
+
#### Generating Processor Files
|
70
|
+
|
71
|
+
The following command is used to generate the processor files.
|
72
|
+
|
73
|
+
```sh
|
74
|
+
rails g inboxable:processor --processor_name <processor_name>
|
75
|
+
```
|
76
|
+
|
77
|
+
The options are as follows:
|
78
|
+
|
79
|
+
- `processor_name`: This option is used to specify the name of the processor. An example of a processor name is `user_update_job`.
|
80
|
+
|
81
|
+
## Inbox Model
|
82
|
+
|
83
|
+
The Inbox model is used to store the messages in the inbox. The Inbox model is generated by the gem when you run the `rails g inboxable:install --orm <orm>` command. Based on the ORM that you are using, the gem will generate the appropriate model. The following is the structure of the Inbox model for ActiveRecord and Mongoid.
|
84
|
+
|
85
|
+
### Fields & Attributes
|
86
|
+
|
87
|
+
The Inbox model has the following fields and attributes:
|
88
|
+
|
89
|
+
---
|
90
|
+
| Field | Type | Description |
|
91
|
+
| --- | --- | --- |
|
92
|
+
| `route_name` | String | The routing key that is used to route the message to the appropriate handler. |
|
93
|
+
| `postman_name` | String | The name of the postman that delivered the message. |
|
94
|
+
| `payload` | String | The payload of the message. |
|
95
|
+
| `event_id` | String | The ID of the event. It is used for idempotency. |
|
96
|
+
| `attempts` | Integer | The number of attempts tried to process the message. |
|
97
|
+
| `last_attempted_at` | Time | The time of the last attempt to process the message. |
|
98
|
+
| `processor_class_name` | String | The name of the processor class that is used to process the message (a Sidekiq worker class). |
|
99
|
+
| `metadata` | Hash | The metadata of the message. |
|
100
|
+
---
|
101
|
+
|
102
|
+
### Methods
|
103
|
+
|
104
|
+
The Inbox model has the following methods:
|
105
|
+
|
106
|
+
---
|
107
|
+
| Method | Description |
|
108
|
+
| --- | --- |
|
109
|
+
| `increment_attempt` | Increments the number of attempts to process the message. |
|
110
|
+
| `process` | Processes the message by enqueuing the processor class to Sidekiq. |
|
111
|
+
| `check_threshold_reach` | Checks if the maximum number of attempts to process the message is reached. If so, it sets the status of the message to `failed` and stops processing the message. |
|
112
|
+
| `check_publishing` | Checks if the message is already processed. If so, it stops processing the message. |
|
113
|
+
---
|
114
|
+
|
115
|
+
### Flow
|
116
|
+
|
117
|
+
When an event is received by the Rails application, the Handler class should ensure that this message is delivered to the inbox by creating a new record in the inbox. After the record is created, the Inbox model tries to process **once** the job by enqueuing the processor class to Sidekiq. If the job fails, the `Inboxable::PollingReceiverWorker` will retry the job up to 3 times with a delay of 5 seconds between each retry. If the job is still not processed after 3 attempts, the job will be moved to the dead letter queue.
|
118
|
+
|
119
|
+
## Configuration
|
120
|
+
|
121
|
+
The Inboxable gem provides a number of configuration options that can be used to customize the behavior of the gem. The following is a list of the configuration options that are available.
|
122
|
+
|
123
|
+
---
|
124
|
+
| Option | Description | Default Value | Applied To |
|
125
|
+
| --- | --- |------------------|---|
|
126
|
+
| `orm` | The ORM that is used in the application. | `:active_record` | `/config/initializers/inboxable.rb` |
|
127
|
+
|`INBOXABLE__CRON_POLL_INTERVAL` | The interval in seconds between each poll of the inbox. | `5` | Can be overridden by providing an environment variable with the same name. |
|
128
|
+
|`INBOXABLE__CRON` | The cron expression that is used to poll the inbox. | `*/5 * * * * *` | Can be overridden by providing an environment variable with the same name. |
|
129
|
+
|`INBOXABLE__BATCH_SIZE` | The number of messages to be processed in each batch. | `100` | Can be overridden by providing an environment variable with the same name. |
|
130
|
+
|`INBOXABLE__MAX_ATTEMPTS` | The maximum number of attempts to process a message. | `3` | Can be overridden by providing an environment variable with the same name. |
|
131
|
+
|`INBOXABLE__RETRY_DELAY_IN_SECONDS` | The delay in seconds before retrying to process a message. | `5` | Can be overridden by providing an environment variable with the same name. |
|
132
|
+
---
|
133
|
+
|
134
|
+
## Contributing
|
135
|
+
|
136
|
+
Bug reports and pull requests are welcome on GitHub at <https://github.com/[USERNAME]/inboxable>. This project is intended to be a safe, welcoming space for collaboration, and contributors. Please go to issues page to report any bugs or feature requests. If you would like to contribute, please fork the repository and submit a pull request.
|
137
|
+
|
138
|
+
To, use the gem locally, clone the repository and run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
139
|
+
|
140
|
+
## License
|
141
|
+
|
142
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/inboxable.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'lib/inboxable/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'inboxable'
|
7
|
+
spec.version = Inboxable::VERSION
|
8
|
+
spec.authors = ['Muhammad Nawzad']
|
9
|
+
spec.email = ['hama127n@gmail.com']
|
10
|
+
|
11
|
+
spec.summary = 'An opiniated Gem for Rails applications to implement the transactional inbox pattern.'
|
12
|
+
spec.description = 'An opiniated Gem for Rails applications to implement the transactional inbox pattern.'
|
13
|
+
|
14
|
+
spec.homepage = 'https://github.com/muhammadnawzad/inboxable'
|
15
|
+
spec.license = 'MIT'
|
16
|
+
spec.required_ruby_version = '>= 3.0.0'
|
17
|
+
|
18
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
19
|
+
|
20
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
21
|
+
spec.metadata['source_code_uri'] = 'https://github.com/muhammadnawzad/inboxable'
|
22
|
+
spec.metadata['changelog_uri'] = 'https://github.com/muhammadnawzad/inboxable/blob/main/CHANGELOG.md'
|
23
|
+
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
26
|
+
(File.expand_path(f) == __FILE__) ||
|
27
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
spec.bindir = 'exe'
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
32
|
+
spec.require_paths = ['lib']
|
33
|
+
|
34
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
35
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Inboxable
|
2
|
+
class HandlerGenerator < Rails::Generators::Base
|
3
|
+
|
4
|
+
source_root File.expand_path('../../templates', __dir__)
|
5
|
+
class_option :handler_name, type: :string, default: 'Handler', desc: 'Class name for the handler'
|
6
|
+
class_option :namespace, type: :string, default: 'Namespace', desc: 'Namespace directory for the handler'
|
7
|
+
attr_reader :nested_namespace
|
8
|
+
|
9
|
+
def initialize(*args)
|
10
|
+
super(*args)
|
11
|
+
|
12
|
+
@handler_name = options[:handler_name]
|
13
|
+
@handler_name != 'Handler' || raise('Handler name is required')
|
14
|
+
|
15
|
+
@namespace = options[:namespace].classify.to_s
|
16
|
+
@namespace != 'Namespace' || raise('Namespace is required')
|
17
|
+
|
18
|
+
@nested_namespace = @namespace.include?('::')
|
19
|
+
end
|
20
|
+
|
21
|
+
def determine_target_path
|
22
|
+
base_path = 'app/services/event_handlers'
|
23
|
+
|
24
|
+
if @nested_namespace
|
25
|
+
@namespace.split('::').each do |namespace|
|
26
|
+
base_path = "#{base_path}/#{namespace.underscore.downcase}"
|
27
|
+
end
|
28
|
+
|
29
|
+
"#{base_path}/#{@handler_name}"
|
30
|
+
else
|
31
|
+
"#{base_path}/#{@namespace.underscore.downcase}/#{@handler_name.underscore.downcase}.rb"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def copy_initializer
|
36
|
+
target_path = determine_target_path
|
37
|
+
|
38
|
+
if Rails.root.join(target_path).exist?
|
39
|
+
say_status('skipped', 'Handler already exists')
|
40
|
+
else
|
41
|
+
|
42
|
+
create_file(target_path, <<-FILE
|
43
|
+
module EventHandlers::#{@namespace}
|
44
|
+
class #{@handler_name.classify}
|
45
|
+
def processor_class_name
|
46
|
+
raise NotImplementedError # e.g. 'Processors::ExampleUpdateJob'
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.handle!(_channel, delivery_info, properties, payload)
|
50
|
+
raise ::RabbitCarrots::EventHandlers::Errors::NackMessage if payload.blank?
|
51
|
+
|
52
|
+
begin
|
53
|
+
Inbox.create!(
|
54
|
+
route_name: delivery_info.routing_key,
|
55
|
+
postman_name: delivery_info&.consumer&.queue&.name,
|
56
|
+
payload:,
|
57
|
+
event_id: JSON.parse(payload)['id'],
|
58
|
+
status: :pending,
|
59
|
+
processor_class_name:, # TODO: change this
|
60
|
+
metadata: properties[:headers]
|
61
|
+
)
|
62
|
+
|
63
|
+
rescue ActiveRecord::RecordNotUnique
|
64
|
+
true
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
FILE
|
70
|
+
)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Inboxable
|
2
|
+
class InstallGenerator < Rails::Generators::Base
|
3
|
+
include Rails::Generators::Migration
|
4
|
+
|
5
|
+
source_root File.expand_path('../../templates', __dir__)
|
6
|
+
class_option :orm, type: :string, default: 'activerecord'
|
7
|
+
|
8
|
+
def initialize(*args)
|
9
|
+
super(*args)
|
10
|
+
|
11
|
+
@orm = options[:orm] || 'activerecord'
|
12
|
+
%w[activerecord mongoid].include?(@orm) || raise(ArgumentError, 'Invalid ORM. Only ActiveRecord and Mongoid are supported.')
|
13
|
+
end
|
14
|
+
|
15
|
+
# Copy initializer into user app
|
16
|
+
def copy_initializer
|
17
|
+
copy_file('activerecord_initializer.rb', 'config/initializers/z_inboxable.rb') if @orm == 'activerecord'
|
18
|
+
copy_file('mongoid_initializer.rb', 'config/initializers/z_inboxable.rb') if @orm == 'mongoid'
|
19
|
+
end
|
20
|
+
|
21
|
+
# Copy user information (model & Migrations) into user app
|
22
|
+
def create_user_model
|
23
|
+
target_path = 'app/models/inbox.rb'
|
24
|
+
|
25
|
+
if Rails.root.join(target_path).exist?
|
26
|
+
say_status('skipped', 'Model inbox already exists')
|
27
|
+
else
|
28
|
+
template('activerecrod_inbox.rb', target_path) if @orm == 'activerecord'
|
29
|
+
template('mongoid_inbox.rb', target_path) if @orm == 'mongoid'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Copy migrations
|
34
|
+
def copy_migrations
|
35
|
+
return if @orm == 'mongoid'
|
36
|
+
|
37
|
+
if self.class.migration_exists?('db/migrate', 'create_inboxable_inboxes')
|
38
|
+
say_status('skipped', 'Migration create_inboxable_inboxes already exists')
|
39
|
+
else
|
40
|
+
migration_template('create_inboxable_inboxes.rb', 'db/migrate/create_inboxable_inboxes.rb')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Use to assign migration time otherwise generator will error
|
45
|
+
def self.next_migration_number(_dir)
|
46
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Inboxable
|
2
|
+
class ProcessorGenerator < Rails::Generators::Base
|
3
|
+
|
4
|
+
source_root File.expand_path('../../templates', __dir__)
|
5
|
+
class_option :processor_name, type: :string, default: 'Processor', desc: 'Class name for the processor'
|
6
|
+
|
7
|
+
def initialize(*args)
|
8
|
+
super(*args)
|
9
|
+
|
10
|
+
@processor_name = options[:processor_name].classify.to_s
|
11
|
+
@processor_name != 'Processor' || raise('Processor name is required')
|
12
|
+
end
|
13
|
+
|
14
|
+
def copy_initializer
|
15
|
+
target_path = "app/sidekiq/processors/#{@processor_name.underscore.downcase}.rb"
|
16
|
+
|
17
|
+
if Rails.root.join(target_path).exist?
|
18
|
+
say_status('skipped', 'Processor already exists')
|
19
|
+
else
|
20
|
+
|
21
|
+
create_file(target_path, <<-FILE
|
22
|
+
module Processors
|
23
|
+
class #{@processor_name}
|
24
|
+
include Sidekiq::Job
|
25
|
+
|
26
|
+
def resource_model
|
27
|
+
raise NotImplementedError # e.g. User
|
28
|
+
end
|
29
|
+
|
30
|
+
def perform(id)
|
31
|
+
resource = Inbox.find(id)
|
32
|
+
|
33
|
+
return if resource.processed?
|
34
|
+
|
35
|
+
payload = JSON.parse(resource.payload)['data']
|
36
|
+
|
37
|
+
resource_model.where(id: payload['id']).where('version < :version', version: payload['version']).update_all(
|
38
|
+
version: payload['version']
|
39
|
+
# TODO: add other attributes
|
40
|
+
)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
FILE
|
45
|
+
)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Inboxable
|
2
|
+
class Configuration
|
3
|
+
ALLOWED_ORMS = %i[activerecord mongoid].freeze
|
4
|
+
|
5
|
+
attr_accessor :orm
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
raise Error, 'Sidekiq is not available. Unfortunately, sidekiq must be available for Inboxable to work' unless Object.const_defined?('Sidekiq')
|
9
|
+
raise Error, 'Inboxable Gem uses the sidekiq-cron Gem. Make sure you add it to your project' unless Object.const_defined?('Sidekiq::Cron')
|
10
|
+
raise Error, 'Inboxable Gem only supports Rails but you application does not seem to be a Rails app' unless Object.const_defined?('Rails')
|
11
|
+
raise Error, 'Inboxable Gem only support Rails version 7 and newer' if Rails::VERSION::MAJOR < 7
|
12
|
+
|
13
|
+
Sidekiq::Options[:cron_poll_interval] = ENV.fetch('INBOXABLE__CRON_POLL_INTERVAL', 5).to_i
|
14
|
+
Sidekiq::Cron::Job.create(name: 'InboxablePollingReceiver', cron: ENV.fetch('INBOXABLE__CRON', '*/5 * * * * *'), class: 'Inboxable::PollingReceiverWorker')
|
15
|
+
end
|
16
|
+
|
17
|
+
def orm=(orm)
|
18
|
+
raise ArgumentError, "ORM must be one of #{ALLOWED_ORMS}" unless ALLOWED_ORMS.include?(orm)
|
19
|
+
|
20
|
+
@orm = orm
|
21
|
+
end
|
22
|
+
|
23
|
+
def orm
|
24
|
+
@orm || :activerecord
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'sidekiq'
|
2
|
+
|
3
|
+
module Inboxable
|
4
|
+
class PollingReceiverWorker
|
5
|
+
include Sidekiq::Job
|
6
|
+
|
7
|
+
def perform
|
8
|
+
Inboxable.configuration.orm == :activerecord ? perform_activerecord : perform_mongoid
|
9
|
+
end
|
10
|
+
|
11
|
+
def perform_activerecord
|
12
|
+
Inbox.pending
|
13
|
+
.where(last_attempted_at: [..Time.zone.now, nil])
|
14
|
+
.find_in_batches(batch_size: ENV.fetch('INBOXABLE__BATCH_SIZE', 100).to_i)
|
15
|
+
.each do |batch|
|
16
|
+
batch.each do |inbox|
|
17
|
+
inbox.processor_class_name.constantize.perform_async(inbox.id)
|
18
|
+
inbox.update(last_attempted_at: 1.minute.from_now, status: :processed, allow_processing: false)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def perform_mongoid
|
24
|
+
batch_size = ENV.fetch('INBOXABLE__BATCH_SIZE', 100).to_i
|
25
|
+
Inbox.pending
|
26
|
+
.any_of({ last_attempted_at: ..Time.zone.now }, { last_attempted_at: nil })
|
27
|
+
.each_slice(batch_size) do |batch|
|
28
|
+
batch.each do |inbox|
|
29
|
+
inbox.processor_class_name.constantize.perform_async(inbox.id.to_s)
|
30
|
+
inbox.update(last_attempted_at: 1.minute.from_now, status: :processed, allow_processing: false)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/inboxable.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'inboxable/version'
|
4
|
+
require_relative 'inboxable/configuration'
|
5
|
+
require_relative 'inboxable/polling_receiver_worker'
|
6
|
+
|
7
|
+
module Inboxable
|
8
|
+
class Error < StandardError; end
|
9
|
+
|
10
|
+
class << self
|
11
|
+
attr_accessor :configuration
|
12
|
+
|
13
|
+
def configure
|
14
|
+
@configuration ||= Configuration.new
|
15
|
+
yield(@configuration) if block_given?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class Inbox < ApplicationRecord
|
2
|
+
attribute :allow_processing, :boolean, default: true
|
3
|
+
|
4
|
+
# Callbacks
|
5
|
+
after_commit :process, if: :allow_processing?
|
6
|
+
|
7
|
+
# Scopes and Enums
|
8
|
+
enum status: { pending: 0, processed: 1, failed: 2 }
|
9
|
+
|
10
|
+
def increment_attempt
|
11
|
+
self.attempts = attempts + 1
|
12
|
+
self.last_attempted_at = Time.zone.now
|
13
|
+
end
|
14
|
+
|
15
|
+
def process
|
16
|
+
processor_class_name.constantize.perform_async(id)
|
17
|
+
end
|
18
|
+
|
19
|
+
def check_threshold_reach
|
20
|
+
return if attempts < ENV.fetch('INBOXABLE__MAX_ATTEMPTS', 3)&.to_i
|
21
|
+
|
22
|
+
self.retry_at = Time.zone.now + ENV.fetch('INBOXABLE__RETRY_DELAY_IN_SECONDS', 5)&.to_i&.seconds
|
23
|
+
self.status = :failed
|
24
|
+
self.allow_processing = false
|
25
|
+
end
|
26
|
+
|
27
|
+
def check_publishing
|
28
|
+
self.allow_processing = false if processed?
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class CreateInboxableInboxes < ActiveRecord::Migration[7.0]
|
2
|
+
def change
|
3
|
+
create_table :inboxes do |t|
|
4
|
+
t.string :route_name, null: false, default: '', index: true
|
5
|
+
t.string :postman_name, null: false, default: ''
|
6
|
+
t.text :payload, null: true
|
7
|
+
|
8
|
+
t.string :event_id, null: false, default: '', index: { unique: true }
|
9
|
+
t.integer :status, null: false, default: 0
|
10
|
+
|
11
|
+
t.integer :attempts, null: false, default: 0
|
12
|
+
t.datetime :last_attempted_at, null: true
|
13
|
+
|
14
|
+
t.string :processor_class_name, null: false, default: ''
|
15
|
+
t.jsonb :metadata, default: {}
|
16
|
+
|
17
|
+
t.timestamps
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
class Inbox
|
2
|
+
include Mongoid::Document
|
3
|
+
include Mongoid::Timestamps
|
4
|
+
include SimpleEnum::Mongoid
|
5
|
+
|
6
|
+
field :route_name, type: String
|
7
|
+
field :postman_name, type: String
|
8
|
+
field :payload, type: String
|
9
|
+
field :event_id, type: String
|
10
|
+
field :attempts, type: Integer, default: 0
|
11
|
+
field :last_attempted_at, type: Time
|
12
|
+
field :processor_class_name, type: String
|
13
|
+
field :metadata, type: Hash, default: {}
|
14
|
+
|
15
|
+
# Indexes
|
16
|
+
index({ event_id: 1 }, unique: true)
|
17
|
+
|
18
|
+
attr_accessor :allow_processing
|
19
|
+
|
20
|
+
as_enum :status, %i[pending processed failed], field: { type: String, default: 'pending' }, map: :string
|
21
|
+
|
22
|
+
validates :processor_class_name, presence: true
|
23
|
+
|
24
|
+
after_create :process, if: proc { |resource| resource.allow_processing == true }
|
25
|
+
|
26
|
+
def increment_attempt
|
27
|
+
self.attempts = attempts + 1
|
28
|
+
self.last_attempted_at = Time.zone.now
|
29
|
+
end
|
30
|
+
|
31
|
+
def process
|
32
|
+
processor_class_name.constantize.perform_async(id.to_s)
|
33
|
+
end
|
34
|
+
|
35
|
+
def check_threshold_reach
|
36
|
+
return if attempts < ENV.fetch('INBOXABLE__MAX_ATTEMPTS', 3)&.to_i
|
37
|
+
|
38
|
+
self.retry_at = Time.zone.now + ENV.fetch('INBOXABLE__RETRY_DELAY_IN_SECONDS', 5)&.to_i&.seconds
|
39
|
+
self.status = :failed
|
40
|
+
self.allow_processing = false
|
41
|
+
end
|
42
|
+
|
43
|
+
def check_publishing
|
44
|
+
self.allow_processing = false if processed?
|
45
|
+
end
|
46
|
+
end
|
data/sig/inboxable.rbs
ADDED
metadata
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: inboxable
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Muhammad Nawzad
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-01-22 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: An opiniated Gem for Rails applications to implement the transactional
|
14
|
+
inbox pattern.
|
15
|
+
email:
|
16
|
+
- hama127n@gmail.com
|
17
|
+
executables: []
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- ".rspec"
|
22
|
+
- ".rubocop.yml"
|
23
|
+
- CHANGELOG.md
|
24
|
+
- CODE_OF_CONDUCT.md
|
25
|
+
- LICENSE.txt
|
26
|
+
- README.md
|
27
|
+
- Rakefile
|
28
|
+
- inboxable.gemspec
|
29
|
+
- lib/generators/inboxable/handler_generator.rb
|
30
|
+
- lib/generators/inboxable/install_generator.rb
|
31
|
+
- lib/generators/inboxable/processor_generator.rb
|
32
|
+
- lib/inboxable.rb
|
33
|
+
- lib/inboxable/configuration.rb
|
34
|
+
- lib/inboxable/polling_receiver_worker.rb
|
35
|
+
- lib/inboxable/version.rb
|
36
|
+
- lib/templates/activerecord_initializer.rb
|
37
|
+
- lib/templates/activerecrod_inbox.rb
|
38
|
+
- lib/templates/create_inboxable_inboxes.rb
|
39
|
+
- lib/templates/mongoid_inbox.rb
|
40
|
+
- lib/templates/mongoid_initializer.rb
|
41
|
+
- sig/inboxable.rbs
|
42
|
+
- sig/inboxable/configuration.rbs
|
43
|
+
- sig/inboxable/polling_receiver_worker.rbs
|
44
|
+
homepage: https://github.com/muhammadnawzad/inboxable
|
45
|
+
licenses:
|
46
|
+
- MIT
|
47
|
+
metadata:
|
48
|
+
allowed_push_host: https://rubygems.org
|
49
|
+
homepage_uri: https://github.com/muhammadnawzad/inboxable
|
50
|
+
source_code_uri: https://github.com/muhammadnawzad/inboxable
|
51
|
+
changelog_uri: https://github.com/muhammadnawzad/inboxable/blob/main/CHANGELOG.md
|
52
|
+
rubygems_mfa_required: 'true'
|
53
|
+
post_install_message:
|
54
|
+
rdoc_options: []
|
55
|
+
require_paths:
|
56
|
+
- lib
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.0.0
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: '0'
|
67
|
+
requirements: []
|
68
|
+
rubygems_version: 3.5.4
|
69
|
+
signing_key:
|
70
|
+
specification_version: 4
|
71
|
+
summary: An opiniated Gem for Rails applications to implement the transactional inbox
|
72
|
+
pattern.
|
73
|
+
test_files: []
|