···1414-- See the Licence for the specific language governing permissions and limitations. [cite: 6]
15151616-- migrate:up
1717+PRAGMA foreign_keys = ON;
1818+1719CREATE TABLE users
1820(
1921 id uuid PRIMARY KEY NOT NULL UNIQUE,
···2628(
2729 user_id uuid NOT NULL,
2830 feed_id uuid NOT NULL,
2929- FOREIGN KEY(user_id) REFERENCES users(id),
3030- FOREIGN KEY(feed_id) REFERENCES feeds(id),
3131+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
3232+ FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
3133 UNIQUE(user_id, feed_id)
3234);
33353436CREATE TABLE feeds
3537(
3636- id uuid PRIMARY KEY NOT NULL,
3737- link TEXT NOT NULL UNIQUE,
3838- skip_n_times INT NOT NULL,
3939- failed_n_times INT NOT NULL
3838+ id uuid PRIMARY KEY NOT NULL,
3939+ link TEXT NOT NULL UNIQUE,
4040+ next_check timestamp NOT NULL DEFAULT (unixepoch('now')),
4141+ repeated_failures INT NOT NULL DEFAULT 0
4042);
41434244CREATE TABLE feed_updates
4345(
4446 user_id uuid NOT NULL,
4547 link_to_post TEXT NOT NULL,
4646- FOREIGN KEY (user_id) REFERENCES users (id),
4848+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
4749 UNIQUE(user_id, link_to_post)
4850);
4951
+7-7
db/schema.sql
···1010(
1111 user_id uuid NOT NULL,
1212 feed_id uuid NOT NULL,
1313- FOREIGN KEY(user_id) REFERENCES users(id),
1414- FOREIGN KEY(feed_id) REFERENCES feeds(id),
1313+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
1414+ FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
1515 UNIQUE(user_id, feed_id)
1616);
1717CREATE TABLE feeds
1818(
1919- id uuid PRIMARY KEY NOT NULL,
2020- link TEXT NOT NULL UNIQUE,
2121- skip_n_times INT NOT NULL,
2222- failed_n_times INT NOT NULL
1919+ id uuid PRIMARY KEY NOT NULL,
2020+ link TEXT NOT NULL UNIQUE,
2121+ next_check timestamp NOT NULL DEFAULT (unixepoch('now')),
2222+ repeated_failures INT NOT NULL DEFAULT 0
2323);
2424CREATE TABLE feed_updates
2525(
2626 user_id uuid NOT NULL,
2727 link_to_post TEXT NOT NULL,
2828- FOREIGN KEY (user_id) REFERENCES users (id),
2828+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
2929 UNIQUE(user_id, link_to_post)
3030);
3131-- Dbmate schema migrations
+1
gleam.toml
···3535gleam_crypto = ">= 1.5.1 and < 2.0.0"
3636glaze_oat = ">= 3.0.0 and < 4.0.0"
3737lustre_portal = ">= 1.0.1 and < 2.0.0"
3838+gleam_time = ">= 1.8.0 and < 2.0.0"
38393940[dev_dependencies]
4041gleeunit = ">= 1.0.0 and < 2.0.0"
+1
manifest.toml
···5353gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
5454gleam_otp = { version = ">= 1.2.0 and < 2.0.0" }
5555gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
5656+gleam_time = { version = ">= 1.8.0 and < 2.0.0" }
5657gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
5758group_registry = { version = ">= 1.0.0 and < 2.0.0" }
5859logging = { version = ">= 1.3.0 and < 2.0.0" }
+51-24
src/eater/backend.gleam
···98989999 supervisor.new(supervisor.RestForOne)
100100 |> supervisor.add(pubsub.supervised(registry))
101101- |> supervisor.add(fetcher.factory(
102102- name: fetcher_factory,
103103- starter: fetcher_manager,
104104- registry:,
105105- database:,
106106- ))
107101 |> supervisor.add(sender.factory(
108102 name: sender_factory,
109103 starter: sender_manager,
···111105 database:,
112106 smtp_environment:,
113107 ))
108108+ |> supervisor.add(fetcher.factory(
109109+ name: fetcher_factory,
110110+ starter: fetcher_manager,
111111+ registry:,
112112+ database:,
113113+ ))
114114 |> supervisor.add(backend_manager(names, database, smtp_environment))
115115 |> supervisor.supervised()
116116}
117117118118// main api ---------------------------------------------------------------------
119119120120+pub type NewSubscriptionError {
121121+ InvalidFeed(fetcher.FetchError)
122122+ Database(DatabaseErrorKind)
123123+}
124124+125125+pub type DatabaseErrorKind {
126126+ FailedToAddFeed
127127+ FailedToAddSubscription
128128+}
129129+120130/// add a new subscription for a user
121131/// - save to database
122132/// - notify backend processes
···125135 backend backend: Reference,
126136 user user: user.User,
127137 feed feed: rss.Location,
128128-) -> Result(Nil, Nil) {
138138+) -> Result(Nil, NewSubscriptionError) {
139139+ // verify the feed is actually a valid rss feed
140140+ use _ <- result.try(
141141+ fetcher.fetch_feed(feed.link)
142142+ |> result.map_error(InvalidFeed),
143143+ )
144144+145145+ // add the feed, if it doesnt exist
129146 use _ <- result.try(
130147 database.add_feed(backend.database, feed)
131148 |> result.map_error(fn(error) {
132132- log(woof.Error, "Failed to add user to database", [
133133- woof.field("user-email", user.email),
134134- woof.field("user-id", user.id |> uuid.to_string()),
149149+ log(woof.Error, "Failed to add feed to database", [
150150+ woof.field("feed-url", feed.link |> uri.to_string()),
151151+ woof.field("feed-id", feed.id |> uuid.to_string()),
135152 woof.field("details", string.inspect(error)),
136153 ])
154154+ Database(FailedToAddFeed)
137155 }),
138156 )
139157158158+ // add the subscription
140159 use _ <- result.try(
141160 database.add_subscription(backend.database, user, feed)
142161 |> result.map_error(fn(error) {
143143- log(woof.Error, "Failed to add user to database", [
162162+ log(woof.Error, "Failed to add subscription to database", [
144163 woof.field("user-email", user.email),
145164 woof.field("user-id", user.id |> uuid.to_string()),
165165+ woof.field("feed-url", feed.link |> uri.to_string()),
166166+ woof.field("feed-id", feed.id |> uuid.to_string()),
146167 woof.field("details", string.inspect(error)),
147168 ])
169169+170170+ Database(FailedToAddSubscription)
148171 }),
149172 )
150173174174+ // notify the backend
151175 let backend = process.named_subject(backend.name)
152176 process.send(backend, NewSubscription(user, feed))
153177 |> Ok
···290314 // external messages ----------------------------------------------------------
291315 /// add a subscription for a user
292316 ///
293293- /// - save to database
294317 /// - notify sender
295318 /// - start sender if not running
296319 /// - start fetcher if not running
···299322300323 /// remove a subscription from a user
301324 ///
302302- /// - delete from database
303325 /// - notify sender
304326 /// - if noone is subscribed
305327 /// - stop fetcher
···489511 // - stop fetcher
490512 fetcher.stop_for(state.names.fetcher_manager, feed)
491513 // - remove feed from database
492492- use _ <- try_twice(
493493- fn() { database.delete_feed(feed, state.database) },
494494- otherwise: log_and_stop(
495495- state.logger,
496496- "Failed to delete feed from database",
497497- [
498498- woof.field("feed", feed.link |> uri.to_string()),
499499- ],
500500- ),
501501- )
502514503503- actor.continue(state)
515515+ case database.delete_feed(state.database, feed) {
516516+ Ok(_) -> actor.continue(state)
517517+ Error(error) -> {
518518+ woof.log(
519519+ state.logger,
520520+ woof.Error,
521521+ "Failed to delete feed from database",
522522+ [
523523+ woof.field("feed-url", feed.link |> uri.to_string()),
524524+ woof.field("feed-id", feed.id |> uuid.to_string()),
525525+ woof.field("details", string.inspect(error)),
526526+ ],
527527+ )
528528+ actor.stop_abnormal("Failed to delete feed from database")
529529+ }
530530+ }
504531 }
505532 _ -> actor.continue(state)
506533 }
+39-23
src/eater/database.gleam
···1919import eater/sql
2020import eater/user
2121import gleam/dynamic/decode
2222+import gleam/float
2223import gleam/list
2324import gleam/option.{Some}
2425import gleam/result
2626+import gleam/time/timestamp
2527import gleam/uri
2628import parrot/dev
2729import sqlight
···3739 feed feed: rss.Location,
3840) -> Result(rss.Location, sqlight.Error) {
3941 let #(sql, with) =
4040- sql.add_feed(
4141- feed.id |> uuid.to_bit_array(),
4242- feed.link |> uri.to_string(),
4343- failed_n_times: feed.failed_n_times,
4444- skip_n_times: feed.skip_n_times,
4545- )
4242+ sql.add_feed(feed.id |> uuid.to_bit_array(), feed.link |> uri.to_string())
46434744 let with = list.map(with, parrot_to_sqlight)
4845···6865 rss.Location(
6966 id:,
7067 link:,
7171- failed_n_times: feed.failed_n_times,
7272- skip_n_times: feed.skip_n_times,
6868+ next_check: feed.next_check,
6969+ repeated_failures: feed.repeated_failures,
7370 )
7471 })
7572 |> Ok
···89869087 // it is a `:one` query with limit 1
9188 list.map(feeds, fn(feed) {
9292- let sql.FeedByLink(id:, link:, skip_n_times:, failed_n_times:) = feed
8989+ let sql.FeedByLink(id:, link:, next_check:, repeated_failures:) = feed
93909491 let assert Ok(id) = uuid.from_bit_array(id)
9592 as "invalid UUID from db UUID column?!"
96939794 let assert Ok(link) = uri.parse(link) as "Invalid uri from database?!"
98959999- rss.Location(id:, link:, failed_n_times:, skip_n_times:)
9696+ rss.Location(id:, link:, next_check:, repeated_failures:)
10097 })
10198 |> Ok
10299}
103100104101/// delete a feed
102102+///
103103+/// also deletes all associated subscriptions
105104///
106105pub fn delete_feed(
107107- feed feed: rss.Location,
108106 from on: sqlight.Connection,
107107+ feed feed: rss.Location,
109108) -> Result(Nil, sqlight.Error) {
110109 let #(sql, with) = sql.delete_feed(id: feed.id |> uuid.to_bit_array)
111110 let with = list.map(with, parrot_to_sqlight)
···114113 |> result.replace(Nil)
115114}
116115117117-/// persist changes to `failed_n_times` and `skip_n_times`
116116+/// persist changes to `feed.next_check`
118117///
119119-pub fn update_feed_cooldown(
118118+pub fn update_feed_next_check(
119119+ in on: sqlight.Connection,
120120 feed feed: rss.Location,
121121+) -> Result(Nil, sqlight.Error) {
122122+ let rss.Location(id:, link: _, next_check:, repeated_failures: _) = feed
123123+124124+ let #(sql, with) =
125125+ sql.update_feed_next_check(next_check:, id: uuid.to_bit_array(id))
126126+127127+ let with = list.map(with, parrot_to_sqlight)
128128+129129+ sqlight.query(sql, on:, with:, expecting: decode.success(""))
130130+ |> result.replace(Nil)
131131+}
132132+133133+/// persist changes to `failed_n_times` and `skip_n_times`
134134+///
135135+pub fn update_feed_repeated_failures(
121136 in on: sqlight.Connection,
137137+ feed feed: rss.Location,
122138) -> Result(Nil, sqlight.Error) {
123123- let rss.Location(id:, link: _, failed_n_times:, skip_n_times:) = feed
139139+ let rss.Location(id:, link: _, next_check: _, repeated_failures:) = feed
124140125141 let #(sql, with) =
126126- sql.update_feed_cooldown(
127127- skip_n_times:,
128128- failed_n_times:,
142142+ sql.update_feed_repeated_failures(
143143+ repeated_failures:,
129144 id: uuid.to_bit_array(id),
130145 )
131146···319334 as "Invalid UUID from datbase?!"
320335321336 use link <- option.then(feed.feed_link)
322322- use failed_n_times <- option.then(feed.feed_failed)
323323- use skip_n_times <- option.then(feed.feed_skip)
337337+ use next_check <- option.then(feed.next_check)
338338+ use repeated_failures <- option.then(feed.repeated_failures)
324339325340 let assert Ok(link) = uri.parse(link) as "Invalid uri from database?!"
326341327327- rss.Location(id:, link:, failed_n_times:, skip_n_times:)
342342+ rss.Location(id:, link:, next_check:, repeated_failures:)
328343 |> Some
329344 }
330345 |> option.to_result(Nil)
···368383 rss.Location(
369384 id:,
370385 link:,
371371- failed_n_times: feed.failed_n_times,
372372- skip_n_times: feed.skip_n_times,
386386+ next_check: feed.next_check,
387387+ repeated_failures: feed.repeated_failures,
373388 ),
374389 feed.count,
375390 )
···389404 dev.ParamNullable(x) -> sqlight.nullable(fn(a) { parrot_to_sqlight(a) }, x)
390405 dev.ParamList(_) -> panic as "sqlite does not implement lists"
391406 dev.ParamDate(_) -> panic as "date parameter needs to be implemented"
392392- dev.ParamTimestamp(_) -> panic as "sqlite does not support timestamps"
407407+ dev.ParamTimestamp(timestamp) ->
408408+ sqlight.int(timestamp.to_unix_seconds(timestamp) |> float.round())
393409 dev.ParamDynamic(_) -> panic as "cannot process dynamic parameter"
394410 }
395411}
+28-25
src/eater/feed/rss.gleam
···1717// See the Licence for the specific language governing permissions and limitations. [cite: 6]
18181919import gleam/dynamic/decode
2020+import gleam/time/duration
2121+import gleam/time/timestamp
2022import gleam/uri
2123import youid/uuid
2224···7981// location ---------------------------------------------------------------------
80828183pub type Location {
8282- /// `failed_n_times`: how many times (back to back) fetching this url has failed
8484+ /// `repeated_failures`: how many times (back to back) fetching this url has failed
8385 /// +1 when it fails | = 0 when it succeeds
8486 ///
8585- /// `skip_n_times`: how many more times this url should be skipped
8686- /// try to fetch if = 0
8787- /// on failure: set = failed_n_times * 2
8888- ///
8989- Location(id: uuid.Uuid, link: uri.Uri, failed_n_times: Int, skip_n_times: Int)
8787+ Location(
8888+ id: uuid.Uuid,
8989+ link: uri.Uri,
9090+ next_check: timestamp.Timestamp,
9191+ repeated_failures: Int,
9292+ )
9093}
91949295/// creates a new Location using the given `link`
9396/// sets a new uuid
9497///
9595-pub fn new_location(link link: uri.Uri) {
9696- Location(id: uuid.v7(), link:, failed_n_times: 0, skip_n_times: 0)
9898+pub fn new_location(link link: uri.Uri) -> Location {
9999+ Location(
100100+ id: uuid.v7(),
101101+ link:,
102102+ next_check: timestamp.system_time(),
103103+ repeated_failures: 0,
104104+ )
97105}
981069999-/// update a location in case of failure
107107+/// increment the failure count
100108///
101101-pub fn failure(location: Location) {
102102- Location(
103103- ..location,
104104- failed_n_times: location.failed_n_times + 1,
105105- skip_n_times: { location.failed_n_times + 1 } * 2,
106106- )
109109+pub fn failure(location: Location) -> Location {
110110+ Location(..location, repeated_failures: location.repeated_failures + 1)
107111}
108112109109-/// applies one round of cooldown
113113+/// resets the failure count to 0
110114///
111111-pub fn cooldown(location: Location) {
112112- case location {
113113- Location(skip_n_times:, ..) if skip_n_times > 0 ->
114114- Location(..location, skip_n_times: location.skip_n_times - 1)
115115- _ -> Location(..location, skip_n_times: 0)
116116- }
115115+pub fn success(location: Location) -> Location {
116116+ Location(..location, repeated_failures: 0)
117117}
118118119119-/// resets the cooldown to 0 0
119119+/// update the locations `next_check` using the current system time and the supplied duration
120120///
121121-pub fn reset_cooldown(location: Location) {
122122- Location(..location, skip_n_times: 0, failed_n_times: 0)
121121+pub fn next_check_in(feed: Location, duration: duration.Duration) -> Location {
122122+ Location(
123123+ ..feed,
124124+ next_check: timestamp.system_time() |> timestamp.add(duration),
125125+ )
123126}
+353-212
src/eater/fetcher.gleam
···1919import eater/database
2020import eater/feed/rss
2121import eater/pubsub
2222+import gleam/bool
2223import gleam/dict
2324import gleam/erlang/process
2525+import gleam/float
2426import gleam/http
2527import gleam/http/request
2628import gleam/http/response
2729import gleam/httpc
3030+import gleam/int
2831import gleam/list
3232+import gleam/order
2933import gleam/otp/actor
3034import gleam/otp/factory_supervisor as factory
3135import gleam/otp/static_supervisor
3236import gleam/otp/supervision
3337import gleam/result
3438import gleam/string
3939+import gleam/time/calendar
4040+import gleam/time/duration
4141+import gleam/time/timestamp
3542import gleam/uri
3643import parsed_it/xml
3744import sqlight
3845import woof
3946import youid/uuid
40474141-const interval_in_ms: Int = 3_600_000
4848+/// this should really be a constant but
4949+/// is currently not supported
5050+///
5151+fn duration_between_checks() {
5252+ duration.milliseconds(3_600_000)
5353+}
42544355/// log with module related structured data
4456///
···140152 |> woof.log(level, message, fields)
141153 }
142154143143- supervision.worker(fn() {
144144- log(woof.Info, "Starting", [])
155155+ use <- supervision.worker
156156+157157+ actor.new_with_initialiser(100, fn(self) {
158158+ log(woof.Info, "Starting", [
159159+ woof.field("pid", string.inspect(process.self())),
160160+ ])
145161146146- actor.new_with_initialiser(100, fn(self) {
147147- process.send_after(self, 150, StartAll)
162162+ process.send_after(self, 1000, StartAll)
148163149149- actor.initialised(ManagerState(
150150- self:,
151151- factory:,
152152- registry:,
153153- database:,
154154- fetchers: dict.new(),
155155- ))
156156- |> Ok
157157- })
158158- |> actor.on_message(fn(state, message) {
159159- case message {
160160- StartAll -> {
161161- case database.all_feeds(database) {
162162- Ok(feeds) -> {
163163- // start all the fetchers
164164- list.map(feeds, fn(feed) {
165165- process.send(state.self, StartFor(feed))
166166- })
164164+ actor.initialised(ManagerState(
165165+ self:,
166166+ factory:,
167167+ registry:,
168168+ database:,
169169+ fetchers: dict.new(),
170170+ ))
171171+ |> Ok
172172+ })
173173+ |> actor.on_message(fn(state, message) {
174174+ case message {
175175+ StartAll -> {
176176+ case database.all_feeds(database) {
177177+ Ok(feeds) -> {
178178+ // start all the fetchers
179179+ list.map(feeds, fn(feed) {
180180+ process.send(state.self, StartFor(feed))
181181+ })
167182168168- actor.continue(state)
169169- }
170170- // have the supervision handle retrying
171171- Error(_) -> actor.stop_abnormal("Failed to get feeds from database")
183183+ actor.continue(state)
172184 }
173173-174174- actor.continue(state)
185185+ // have the supervision handle retrying
186186+ Error(_) -> actor.stop_abnormal("Failed to get feeds from database")
175187 }
176188177177- // all starts go through this
178178- FetcherStarted(Ok(started), feed) -> {
179179- // if there is an existing sender for this
180180- // tell it to shut down
181181- case dict.get(state.fetchers, feed.id) {
182182- Ok(actor) ->
183183- case process.is_alive(actor.pid) {
184184- True -> process.send_exit(actor.pid)
185185- False -> Nil
186186- }
187187- Error(_) -> Nil
188188- }
189189+ actor.continue(state)
190190+ }
189191190190- let state =
191191- ManagerState(
192192- ..state,
193193- fetchers: dict.insert(state.fetchers, feed.id, started),
194194- )
192192+ // all starts go through this
193193+ FetcherStarted(Ok(started), feed) -> {
194194+ // if there is an existing sender for this
195195+ // tell it to shut down
196196+ case dict.get(state.fetchers, feed.id) {
197197+ Ok(actor) ->
198198+ case process.is_alive(actor.pid) {
199199+ True -> process.send_exit(actor.pid)
200200+ False -> Nil
201201+ }
202202+ Error(_) -> Nil
203203+ }
204204+205205+ let state =
206206+ ManagerState(
207207+ ..state,
208208+ fetchers: dict.insert(state.fetchers, feed.id, started),
209209+ )
195210196196- log(woof.Info, "Fetcher has registered itself", [
197197- woof.field("feed", feed.link |> uri.to_string()),
198198- ])
211211+ log(woof.Info, "Fetcher has registered itself", [
212212+ woof.field("feed", feed.link |> uri.to_string()),
213213+ woof.field("pid", started.pid |> string.inspect()),
214214+ ])
199215200200- actor.continue(state)
201201- }
216216+ actor.continue(state)
217217+ }
202218203203- FetcherStarted(Error(start_error), feed) -> {
204204- log(woof.Error, "Failed to start fetcher", [
205205- woof.field("feed", feed.link |> uri.to_string()),
206206- woof.field("details", string.inspect(start_error)),
207207- ])
219219+ FetcherStarted(Error(start_error), feed) -> {
220220+ log(woof.Error, "Failed to start fetcher", [
221221+ woof.field("feed", feed.link |> uri.to_string()),
222222+ woof.field("details", string.inspect(start_error)),
223223+ ])
208224209209- actor.stop_abnormal(
210210- "Fetcher failed to start, i dont know how this would happen",
211211- )
225225+ actor.stop_abnormal(
226226+ "Fetcher failed to start, i dont know how this would happen",
227227+ )
228228+ }
229229+ // runtime additions ----------------------------------------------------
230230+ StartFor(feed) -> {
231231+ let start_new = fn() {
232232+ let _ =
233233+ start_new(
234234+ factory,
235235+ Start(database:, registry:, feed:, manager: name),
236236+ )
237237+ Nil
212238 }
213213- // runtime additions ----------------------------------------------------
214214- StartFor(feed) -> {
215215- let start_new = fn() {
216216- let _ =
217217- start_new(
218218- factory,
219219- Start(database:, registry:, feed:, manager: name),
220220- )
221221- Nil
222222- }
223239224224- case dict.get(state.fetchers, feed.id) {
225225- Ok(actor) -> {
226226- case process.is_alive(actor.pid) {
227227- True -> Nil
228228- False -> start_new()
229229- }
240240+ case dict.get(state.fetchers, feed.id) {
241241+ Ok(actor) -> {
242242+ case process.is_alive(actor.pid) {
243243+ True -> Nil
244244+ False -> start_new()
230245 }
231231- Error(_) -> start_new()
232246 }
233233-234234- actor.continue(state)
247247+ Error(_) -> start_new()
235248 }
236236- StopFor(feed) -> {
237237- case dict.get(state.fetchers, feed.id) {
238238- Ok(actor) -> {
239239- case process.is_alive(actor.pid) {
240240- True -> process.send_exit(actor.pid)
241241- False -> Nil
242242- }
249249+250250+ actor.continue(state)
251251+ }
252252+ StopFor(feed) -> {
253253+ case dict.get(state.fetchers, feed.id) {
254254+ Ok(actor) -> {
255255+ case process.is_alive(actor.pid) {
256256+ True -> process.send_exit(actor.pid)
257257+ False -> Nil
243258 }
244244- Error(_) -> Nil
245259 }
246246-247247- actor.continue(state)
260260+ Error(_) -> Nil
248261 }
249249- RestartFor(user) -> {
250250- process.send(state.self, StopFor(user))
251251- process.send(state.self, StartFor(user))
262262+263263+ actor.continue(state)
264264+ }
265265+ RestartFor(user) -> {
266266+ process.send(state.self, StopFor(user))
267267+ process.send(state.self, StartFor(user))
252268253253- actor.continue(state)
254254- }
255255- RecheckFor(feed) -> {
256256- case dict.get(state.fetchers, feed.id) {
257257- Ok(actor) -> {
258258- case process.is_alive(actor.pid) {
259259- True -> {
260260- process.send(actor.data, CheckFeeds)
261261- }
262262- False -> Nil
269269+ actor.continue(state)
270270+ }
271271+ RecheckFor(feed) -> {
272272+ case dict.get(state.fetchers, feed.id) {
273273+ Ok(actor) -> {
274274+ case process.is_alive(actor.pid) {
275275+ True -> {
276276+ process.send(actor.data, CheckFeed)
263277 }
278278+ False -> Nil
264279 }
265265- Error(_) -> Nil
266280 }
267267-268268- actor.continue(state)
281281+ Error(_) -> Nil
269282 }
283283+284284+ actor.continue(state)
270285 }
271271- })
272272- |> actor.named(name)
273273- |> actor.start()
286286+ }
274287 })
288288+ |> actor.named(name)
289289+ |> actor.start()
275290}
276291277292/// start a new fetcher in the fetcher factory
···299314}
300315301316pub type Message {
302302- CheckFeeds
317317+ CheckFeed
303318}
304319305320type State {
306321 /// `self` - own subject
322322+ /// `fetch_timer` - the timer that raises `CheckFeed` every `duration_between_checks`
307323 /// `feed` - the feed this instance fetches
308324 /// `publish_to` - the group registry to publish the items to
309325 /// `database` - used to update the feed cooldowns
···321337fn fetcher(args: Start) -> Result(actor.Started(_), actor.StartError) {
322338 let Start(feed:, registry:, database:, manager:) = args
323339324324- log(woof.Info, "Starting", [woof.field("feed", feed.link |> uri.to_string())])
325325-326340 let started =
327341 actor.new_with_initialiser(100, fn(self) {
328328- // TODO: persist the next timestamp to check at to the database
329329- // so the actor restarting doesnt preeptively recheck the feed
330330- let fetch_timer = process.send_after(self, 150, CheckFeeds)
342342+ log(woof.Info, "Starting", [
343343+ woof.field("feed", feed.link |> uri.to_string()),
344344+ woof.field("pid", string.inspect(process.self())),
345345+ ])
346346+347347+ let fetch_timer = case
348348+ timestamp.compare(feed.next_check, timestamp.system_time())
349349+ {
350350+ // next_check is in the future
351351+ // send a CheckFeed message accordingly
352352+ order.Gt -> {
353353+ let difference =
354354+ feed.next_check
355355+ |> timestamp.difference(timestamp.system_time())
356356+357357+ log(woof.Info, "Next check is at", [
358358+ woof.field(
359359+ "timestamp",
360360+ feed.next_check |> timestamp.to_rfc3339(calendar.local_offset()),
361361+ ),
362362+ ])
363363+364364+ difference
365365+ |> duration.to_milliseconds()
366366+ |> process.send_after(self, _, CheckFeed)
367367+ }
368368+ // next check is right now
369369+ _ -> {
370370+ log(woof.Info, "Next check is being scheduled now", [])
371371+372372+ process.send_after(self, 150, CheckFeed)
373373+ }
374374+ }
331375332376 actor.initialised(State(
333377 self: self,
···353397///
354398fn on_message(state: State, message: Message) -> actor.Next(State, Message) {
355399 case message {
356356- CheckFeeds -> {
400400+ // gets periodically raised to check our feed
401401+ CheckFeed -> {
357402 log(woof.Info, "Checking feed", [
358403 woof.field("feed", state.feed.link |> uri.to_string()),
359404 ])
360405361361- // only actually check the feed if there are people subscribed to it
362362- case pubsub.subscriber_count_feed(state.feed, state.registry) {
363363- [] ->
364364- log(woof.Warning, "Skipping", [
365365- woof.field("feed", state.feed.link |> uri.to_string()),
366366- woof.field("reason", "No sender is subscribed"),
367367- ])
368368- [_, ..] -> {
369369- let handled = handle_feed(state, state.feed)
370370- case handled {
371371- Ok(_) -> Nil
372372- Error(error) ->
373373- log(woof.Error, "Checking feed failed", [
374374- woof.field("feed", state.feed.link |> uri.to_string()),
375375- woof.field("details", string.inspect(error)),
406406+ // if we've failed to fetch the feed too many times
407407+ // stop fetching for this feed, until it gets started again
408408+ case state.feed.repeated_failures {
409409+ failures if failures > 10 -> failed_too_often(state)
410410+ _ -> {
411411+ let reset_timer_and_continue = fn(state: State) {
412412+ // cancel old timer in case we received another CheckFeeds message that wasnt sent by the timer
413413+ // from the Ui for example
414414+ process.cancel_timer(state.fetch_timer)
415415+416416+ // update the `next_check` timestamp in the `rss.Location`
417417+ let feed = rss.next_check_in(state.feed, duration_between_checks())
418418+419419+ // and save it to the database
420420+ case database.update_feed_next_check(state.database, feed) {
421421+ Error(_) ->
422422+ actor.stop_abnormal(
423423+ "Failed to persist new next_check for "
424424+ <> state.feed.id |> uuid.to_string()
425425+ <> " "
426426+ <> state.feed.link |> uri.to_string(),
427427+ )
428428+ // we managed to delete it
429429+ // lets notify the other
430430+ Ok(_) -> {
431431+ // check again after interval has elapsed
432432+ let fetch_timer =
433433+ process.send_after(
434434+ state.self,
435435+ duration_between_checks() |> duration.to_milliseconds(),
436436+ CheckFeed,
437437+ )
438438+439439+ log(woof.Info, "Next check is at", [
440440+ woof.field(
441441+ "timestamp",
442442+ feed.next_check
443443+ |> timestamp.to_rfc3339(calendar.local_offset()),
444444+ ),
445445+ ])
446446+447447+ actor.continue(State(..state, fetch_timer:, feed:))
448448+ }
449449+ }
450450+ }
451451+452452+ // only actually check the feed if there are people subscribed to it
453453+ case pubsub.subscriber_count_feed(state.feed, state.registry) {
454454+ [] -> {
455455+ log(woof.Warning, "Skipping", [
456456+ woof.field("feed-url", state.feed.link |> uri.to_string()),
457457+ woof.field("feed-id", state.feed.id |> uuid.to_string()),
458458+ woof.field("reason", "No sender is subscribed"),
376459 ])
460460+ reset_timer_and_continue(state)
461461+ }
462462+463463+ [_, ..] ->
464464+ case handle_feed_fetching(state) {
465465+ Ok(state) -> reset_timer_and_continue(state)
466466+ Error(stop) -> stop
467467+ }
377468 }
378469 }
379470 }
380380-381381- // cancel old timer in case we received another CheckFeeds message that wasnt sent by the timer
382382- process.cancel_timer(state.fetch_timer)
383383-384384- // check again after interval has elapsed
385385- let fetch_timer =
386386- process.send_after(state.self, interval_in_ms, CheckFeeds)
387387-388388- actor.continue(State(..state, fetch_timer:))
389471 }
390472 }
391473}
392474393393-type HandleFeedError {
394394- FailedToDecreaseCooldown(sqlight.Error)
395395- FailedToPersistFailure(sqlight.Error, FetchError)
475475+/// stop and notify that we arent fetching this feed anymore
476476+/// - notify senders through pubsub
477477+/// - stop
478478+///
479479+fn failed_too_often(state: State) -> actor.Next(State, Message) {
480480+ log(woof.Warning, "Feed failed too often, fetching is being paused", [
481481+ woof.field("feed-url", state.feed.link |> uri.to_string()),
482482+ woof.field("feed-id", state.feed.id |> uuid.to_string()),
483483+ woof.int_field("feed-failures", state.feed.repeated_failures),
484484+ woof.bool_field("failure-too-high", state.feed.repeated_failures > 10),
485485+ ])
486486+487487+ // tell the senders so they can message subscribed users about it
488488+ pubsub.publish_feed_pausing(state.registry, state.feed)
489489+490490+ // stop
491491+ actor.stop()
396492}
397493398398-// TODO: clean up
399399-/// tries to fetch a given feed and publish it to the group registry
400400-///
401401-/// handles the failure cases and incrementsthe feed's cooldown accordingly
494494+/// handle fetching a feed and the associated failures
402495///
403403-fn handle_feed(
404404- state: State,
405405- location: rss.Location,
406406-) -> Result(Nil, HandleFeedError) {
407407- let feed = fetch_feed(location)
408408-496496+/// the error is only used to `Error(actor.stop_abnormal(..))`
497497+///
498498+fn handle_feed_fetching(state: State) -> Result(State, actor.Next(State, a)) {
499499+ let feed = fetch_feed(state.feed.link)
409500 case feed {
501501+ Error(error) -> {
502502+ // log that something went wrong while fetching
503503+ log(woof.Warning, "Failed to fetch feed", [
504504+ woof.field("feed-url", state.feed.link |> uri.to_string()),
505505+ woof.field("feed-id", state.feed.id |> uuid.to_string()),
506506+ ..describe_fetch_error(error)
507507+ ])
508508+509509+ // update the feed failure stats
510510+ let feed = rss.failure(state.feed)
511511+512512+ // persist the new failure stats
513513+ use _ <- result.try(
514514+ database.update_feed_repeated_failures(state.database, feed)
515515+ |> result.replace_error(actor.stop_abnormal(
516516+ "Failed to increase failure count",
517517+ )),
518518+ )
519519+520520+ // continue with the new feed
521521+ Ok(State(..state, feed:))
522522+ }
410523 Ok(feed) -> {
411524 feed.channel.items
412525 // reverse so we publish the oldest items first
···419532 )
420533 })
421534422422- case location.failed_n_times != 0 || location.skip_n_times != 0 {
423423- True ->
424424- rss.reset_cooldown(location)
425425- |> database.update_feed_cooldown(state.database)
426426- |> result.map_error(fn(error) {
427427- log(woof.Error, "Failed to reset feed cooldown", [
428428- woof.field("details", string.inspect(error)),
429429- ])
430430- })
431431- |> result.unwrap(Nil)
432432- False -> Nil
433433- }
434434- |> Ok
535535+ Ok(state)
435536 }
436436- Error(OnCooldown) ->
437437- rss.cooldown(location)
438438- |> database.update_feed_cooldown(state.database)
439439- |> result.map_error(FailedToDecreaseCooldown)
537537+ }
538538+}
539539+540540+// feed fetching ----------------------------------------------------------------
541541+542542+pub type FetchError {
543543+ InvalidUri
544544+ PathNotXml
545545+ RequestFailed(httpc.HttpError)
546546+ Non200Response(response.Response(String))
547547+ NonXmlResponse
548548+ FailedToParseRssFeed(xml.XmlDecodeError)
549549+}
550550+551551+fn describe_fetch_error(fetch_error: FetchError) {
552552+ case fetch_error {
553553+ InvalidUri -> [
554554+ woof.field("error-variant", "InvalidUri"),
555555+ woof.field(
556556+ "description",
557557+ "The supplied uri could not be converted into a request",
558558+ ),
559559+ ]
560560+ PathNotXml -> [
561561+ woof.field("error-variant", "InvalidUri"),
562562+ woof.field("description", "The supplied uri does not end with .xml"),
563563+ ]
564564+ RequestFailed(httpc_error) -> [
565565+ woof.field("error-variant", "RequestFailed"),
566566+ woof.field("description", "The request failed"),
567567+ woof.field("httpc-error", case httpc_error {
568568+ httpc.InvalidUtf8Response -> "Invalid UTF8"
569569+ httpc.FailedToConnect(_, _) -> "Failed to connect"
570570+ httpc.ResponseTimeout -> "Timeout"
571571+ }),
572572+ ]
573573+ NonXmlResponse -> [
574574+ woof.field("error-variant", "NonXmlResponse"),
575575+ woof.field("description", "The response was not text/xml"),
576576+ ]
577577+ FailedToParseRssFeed(error) -> [
578578+ woof.field("error-variant", "FailedToParseRssFeed"),
579579+ woof.field("description", "The returned xml could not be parsed"),
580580+ ..case error {
581581+ xml.InvalidXml(error) -> [
582582+ woof.field("xml-error", "Invalid xml: " <> error),
583583+ ]
584584+ xml.UnableToDecode(decode_errors) ->
585585+ list.index_fold(decode_errors, [], fn(acc, error, index) {
586586+ let field_start = fn() { "decode-error-" <> int.to_string(index) }
440587441441- Error(error) ->
442442- rss.failure(location)
443443- |> database.update_feed_cooldown(state.database)
444444- |> result.map_error(FailedToPersistFailure(_, error))
588588+ [
589589+ woof.field(field_start() <> "expected", error.expected),
590590+ woof.field(field_start() <> "found", error.found),
591591+ woof.field(field_start() <> "path", string.join(error.path, ".")),
592592+ ..acc
593593+ ]
594594+ })
595595+ }
596596+ ]
597597+ Non200Response(response) -> [
598598+ woof.field("error-variant", "Non200Response"),
599599+ woof.int_field("status-code", response.status),
600600+ ]
445601 }
446602}
447603448448-/// gets an rss feed from a given location and parses it into a `rss.Feed`
604604+/// gets an rss feed from a given uri and parses it into a `rss.Feed`
449605///
450450-fn fetch_feed(location: rss.Location) -> Result(rss.Feed, FetchError) {
451451- case location {
452452- rss.Location(skip_n_times: 0, ..) -> {
453453- log(woof.Info, "Fetching", [
454454- woof.field("feed", location.link |> uri.to_string()),
455455- ])
606606+pub fn fetch_feed(feed: uri.Uri) -> Result(rss.Feed, FetchError) {
607607+ use <- bool.guard(
608608+ bool.negate(feed.path |> string.ends_with(".xml")),
609609+ return: Error(PathNotXml),
610610+ )
456611457457- use req <- result.try(
458458- request.from_uri(location.link)
459459- |> result.replace_error(FailedToParseUrl(location.link)),
460460- )
612612+ use req <- result.try(
613613+ request.from_uri(feed)
614614+ |> result.replace_error(InvalidUri),
615615+ )
461616462462- let req =
463463- req
464464- |> request.set_method(http.Get)
617617+ let req =
618618+ req
619619+ |> request.set_method(http.Get)
465620466466- use response <- result.try(
467467- httpc.send(req) |> result.map_error(RequestFailed),
468468- )
621621+ use response <- result.try(httpc.send(req) |> result.map_error(RequestFailed))
469622470470- case response {
471471- response.Response(status: 200, headers: _, body:) ->
623623+ case response {
624624+ response.Response(status: 200, headers: _, body:) -> {
625625+ case
626626+ response.get_header(response, "Content-Type")
627627+ |> result.map(string.contains(_, "xml"))
628628+ {
629629+ Ok(True) ->
472630 xml.parse(body, rss.decoder())
473631 |> result.map_error(FailedToParseRssFeed)
474474- response -> {
475475- log(woof.Warning, "Fetching failed", [
476476- woof.field("feed", location.link |> uri.to_string()),
477477- woof.int_field("status", response.status),
478478- ])
479479- Error(Non200Response(response))
480480- }
632632+ _ -> Error(NonXmlResponse)
481633 }
482634 }
483483- _ -> {
484484- log(woof.Info, "Skipping", [
485485- woof.field("feed", location.link |> uri.to_string()),
486486- woof.field("reason", "cooldown"),
487487- ])
488488- Error(OnCooldown)
635635+636636+ response -> {
637637+ Error(Non200Response(response))
489638 }
490639 }
491640}
492492-493493-type FetchError {
494494- FailedToParseUrl(uri.Uri)
495495- RequestFailed(httpc.HttpError)
496496- Non200Response(response.Response(String))
497497- FailedToParseRssFeed(xml.XmlDecodeError)
498498- OnCooldown
499499-}
+21
src/eater/pubsub.gleam
···60606161pub type Message {
6262 FeedUpdate(update: rss.FeedUpdate)
6363+ FailedToFetchTooManyTimes(rss.Location)
6364}
64656566/// get the string 'channel' for a given `rss.Location`
···9394 log(woof.Info, "Feed published update", [
9495 woof.field("feed", feed.link |> uri.to_string()),
9596 woof.field("update", update.new.link),
9797+ ])
9898+}
9999+100100+/// publish info about a feed being paused because it has failed fetching too many times
101101+///
102102+pub fn publish_feed_pausing(
103103+ registry registry: Registry,
104104+ feed feed: rss.Location,
105105+) -> Nil {
106106+ let registry = group_registry.get_registry(registry)
107107+ let channel = feed_channel(feed)
108108+109109+ let members = group_registry.members(registry, channel)
110110+111111+ list.map(members, process.send(_, FailedToFetchTooManyTimes(feed)))
112112+113113+ log(woof.Warning, "Feed pausing has been published", [
114114+ woof.field("feed-id", feed.id |> uuid.to_string()),
115115+ woof.field("feed-url", feed.link |> uri.to_string()),
116116+ woof.int_field("feed-failure-count", feed.repeated_failures),
96117 ])
97118}
98119
···2323import gleam/int
2424import gleam/option.{type Option}
2525import gleam/result
2626+import gleam/uri
2627import lustre/attribute
2728import lustre/element
2829import lustre/element/html
···8283 html.body([], [
8384 html.h1([], [html.text("Your one time password")]),
8485 html.p([], [html.text(one_time_password)]),
8686+ ]),
8787+ ])
8888+ |> element.to_readable_string,
8989+ ))
9090+}
9191+9292+/// create a message informing a user about a feed being deleted
9393+/// because it has failed to be fetched to many times
9494+///
9595+pub fn feed_pausing_to_email(
9696+ environment: SmtpEnvironment,
9797+ deleted_feed: rss.Location,
9898+ user: user.User,
9999+) -> gcourier.Message {
100100+ gcourier.new_message(sender(environment))
101101+ |> gcourier.set_subject("One of your feeds has been paused")
102102+ |> gcourier.add_recipient(gcourier.To(user.email))
103103+ |> gcourier.set_content(gcourier.Html(
104104+ html.html([], [
105105+ html.body([], [
106106+ html.h1([], [html.text("One of your feeds has been pause")]),
107107+ html.p([], [
108108+ html.text("Checking of "),
109109+ html.a([attribute.href(deleted_feed.link |> uri.to_string())], [
110110+ element.text(deleted_feed.link |> uri.to_string()),
111111+ ]),
112112+ html.text(
113113+ " has been paused. Because fetching or parsing of it has ran into issues too many times.",
114114+ ),
115115+ ]),
85116 ]),
86117 ])
87118 |> element.to_readable_string,
+57-51
src/eater/sql.gleam
···3344import gleam/dynamic/decode
55import gleam/option.{type Option}
66+import gleam/time/timestamp.{type Timestamp}
67import parrot/dev
7888-pub fn add_feed(
99- id id: BitArray,
1010- link link: String,
1111- failed_n_times failed_n_times: Int,
1212- skip_n_times skip_n_times: Int,
1313-) {
99+pub fn add_feed(id id: BitArray, link link: String) {
1410 let sql =
1511 "
1612INSERT OR IGNORE INTO feeds (
1713 id,
1818- link,
1919- failed_n_times,
2020- skip_n_times
1414+ link
2115)
2216values
2323- (?, ?, ?, ?)"
2424- #(sql, [
2525- dev.ParamBitArray(id),
2626- dev.ParamString(link),
2727- dev.ParamInt(failed_n_times),
2828- dev.ParamInt(skip_n_times),
2929- ])
1717+ (?, ?)"
1818+ #(sql, [dev.ParamBitArray(id), dev.ParamString(link)])
3019}
31203221pub type AllFeeds {
3333- AllFeeds(id: BitArray, link: String, skip_n_times: Int, failed_n_times: Int)
2222+ AllFeeds(
2323+ id: BitArray,
2424+ link: String,
2525+ next_check: Timestamp,
2626+ repeated_failures: Int,
2727+ )
3428}
35293630pub fn all_feeds() {
3737- let sql = "SELECT id, link, skip_n_times, failed_n_times FROM feeds"
3131+ let sql = "SELECT id, link, next_check, repeated_failures FROM feeds"
3832 #(sql, [], all_feeds_decoder())
3933}
40344135pub fn all_feeds_decoder() -> decode.Decoder(AllFeeds) {
4236 use id <- decode.field(0, decode.bit_array)
4337 use link <- decode.field(1, decode.string)
4444- use skip_n_times <- decode.field(2, decode.int)
4545- use failed_n_times <- decode.field(3, decode.int)
4646- decode.success(AllFeeds(id:, link:, skip_n_times:, failed_n_times:))
3838+ use next_check <- decode.field(2, dev.datetime_decoder())
3939+ use repeated_failures <- decode.field(3, decode.int)
4040+ decode.success(AllFeeds(id:, link:, next_check:, repeated_failures:))
4741}
48424943pub fn delete_feed(id id: BitArray) {
···5145 #(sql, [dev.ParamBitArray(id)])
5246}
53475454-pub fn update_feed_cooldown(
5555- skip_n_times skip_n_times: Int,
5656- failed_n_times failed_n_times: Int,
4848+pub fn update_feed_next_check(next_check next_check: Timestamp, id id: BitArray) {
4949+ let sql =
5050+ "UPDATE feeds SET next_check = ?
5151+WHERE id = ?"
5252+ #(sql, [dev.ParamTimestamp(next_check), dev.ParamBitArray(id)])
5353+}
5454+5555+pub fn update_feed_repeated_failures(
5656+ repeated_failures repeated_failures: Int,
5757 id id: BitArray,
5858) {
5959 let sql =
6060- "UPDATE feeds SET skip_n_times = ?, failed_n_times = ?
6060+ "UPDATE feeds SET repeated_failures = ?
6161WHERE id = ?"
6262- #(sql, [
6363- dev.ParamInt(skip_n_times),
6464- dev.ParamInt(failed_n_times),
6565- dev.ParamBitArray(id),
6666- ])
6262+ #(sql, [dev.ParamInt(repeated_failures), dev.ParamBitArray(id)])
6763}
68646965pub type FeedByLink {
7070- FeedByLink(id: BitArray, link: String, skip_n_times: Int, failed_n_times: Int)
6666+ FeedByLink(
6767+ id: BitArray,
6868+ link: String,
6969+ next_check: Timestamp,
7070+ repeated_failures: Int,
7171+ )
7172}
72737374pub fn feed_by_link(link link: String) {
7475 let sql =
7575- "SELECT id, link, skip_n_times, failed_n_times FROM feeds WHERE link = ? LIMIT 1"
7676+ "SELECT id, link, next_check, repeated_failures FROM feeds WHERE link = ? LIMIT 1"
7677 #(sql, [dev.ParamString(link)], feed_by_link_decoder())
7778}
78797980pub fn feed_by_link_decoder() -> decode.Decoder(FeedByLink) {
8081 use id <- decode.field(0, decode.bit_array)
8182 use link <- decode.field(1, decode.string)
8282- use skip_n_times <- decode.field(2, decode.int)
8383- use failed_n_times <- decode.field(3, decode.int)
8484- decode.success(FeedByLink(id:, link:, skip_n_times:, failed_n_times:))
8383+ use next_check <- decode.field(2, dev.datetime_decoder())
8484+ use repeated_failures <- decode.field(3, decode.int)
8585+ decode.success(FeedByLink(id:, link:, next_check:, repeated_failures:))
8586}
86878788pub fn add_user(
···189190pub fn add_subscription(user_id user_id: BitArray, feed_id feed_id: BitArray) {
190191 let sql =
191192 "
192192-INSERT INTO subscriptions (
193193+INSERT OR IGNORE INTO subscriptions (
193194 user_id,
194195 feed_id
195196)
···212213 FeedsForUser(
213214 feed_id: Option(BitArray),
214215 feed_link: Option(String),
215215- feed_skip: Option(Int),
216216- feed_failed: Option(Int),
216216+ next_check: Option(Timestamp),
217217+ repeated_failures: Option(Int),
217218 )
218219}
219220···222223 "SELECT
223224 feeds.id AS feed_id,
224225 feeds.link AS feed_link,
225225- feeds.skip_n_times AS feed_skip,
226226- feeds.failed_n_times AS feed_failed
226226+ feeds.next_check AS next_check,
227227+ feeds.repeated_failures AS repeated_failures
227228FROM
228229 subscriptions
229230LEFT JOIN
···237238pub fn feeds_for_user_decoder() -> decode.Decoder(FeedsForUser) {
238239 use feed_id <- decode.field(0, decode.optional(decode.bit_array))
239240 use feed_link <- decode.field(1, decode.optional(decode.string))
240240- use feed_skip <- decode.field(2, decode.optional(decode.int))
241241- use feed_failed <- decode.field(3, decode.optional(decode.int))
242242- decode.success(FeedsForUser(feed_id:, feed_link:, feed_skip:, feed_failed:))
241241+ use next_check <- decode.field(2, decode.optional(dev.datetime_decoder()))
242242+ use repeated_failures <- decode.field(3, decode.optional(decode.int))
243243+ decode.success(FeedsForUser(
244244+ feed_id:,
245245+ feed_link:,
246246+ next_check:,
247247+ repeated_failures:,
248248+ ))
243249}
244250245251pub type FeedSubscriptionCount {
···266272 SubscriptionsPerFeed(
267273 id: BitArray,
268274 link: String,
269269- skip_n_times: Int,
270270- failed_n_times: Int,
275275+ next_check: Timestamp,
276276+ repeated_failures: Int,
271277 count: Int,
272278 )
273279}
···277283 "SELECT
278284 feeds.id,
279285 feeds.link,
280280- feeds.skip_n_times,
281281- feeds.failed_n_times,
286286+ feeds.next_check,
287287+ feeds.repeated_failures,
282288 count(subscriptions.user_id)
283289FROM
284290 feeds
···291297pub fn subscriptions_per_feed_decoder() -> decode.Decoder(SubscriptionsPerFeed) {
292298 use id <- decode.field(0, decode.bit_array)
293299 use link <- decode.field(1, decode.string)
294294- use skip_n_times <- decode.field(2, decode.int)
295295- use failed_n_times <- decode.field(3, decode.int)
300300+ use next_check <- decode.field(2, dev.datetime_decoder())
301301+ use repeated_failures <- decode.field(3, decode.int)
296302 use count <- decode.field(4, decode.int)
297303 decode.success(SubscriptionsPerFeed(
298304 id:,
299305 link:,
300300- skip_n_times:,
301301- failed_n_times:,
306306+ next_check:,
307307+ repeated_failures:,
302308 count:,
303309 ))
304310}
+8-6
src/eater/sql/feeds.sql
···1616-- name: AddFeed :exec
1717INSERT OR IGNORE INTO feeds (
1818 id,
1919- link,
2020- failed_n_times,
2121- skip_n_times
1919+ link
2220)
2321values
2424- (?, ?, ?, ?);
2222+ (?, ?);
25232624-- name: AllFeeds :many
2725SELECT * FROM feeds;
···2927-- name: DeleteFeed :exec
3028DELETE FROM feeds WHERE id = ?;
31293232--- name: UpdateFeedCooldown :exec
3333-UPDATE feeds SET skip_n_times = ?, failed_n_times = ?
3030+-- name: UpdateFeedNextCheck :exec
3131+UPDATE feeds SET next_check = ?
3232+WHERE id = ?;
3333+3434+-- name: UpdateFeedRepeatedFailures :exec
3535+UPDATE feeds SET repeated_failures = ?
3436WHERE id = ?;
35373638-- name: FeedByLink :one
+5-5
src/eater/sql/subscriptions.sql
···1414-- See the Licence for the specific language governing permissions and limitations. [cite: 6]
15151616-- name: AddSubscription :exec
1717-INSERT INTO subscriptions (
1717+INSERT OR IGNORE INTO subscriptions (
1818 user_id,
1919 feed_id
2020)
···3333SELECT
3434 feeds.id AS feed_id,
3535 feeds.link AS feed_link,
3636- feeds.skip_n_times AS feed_skip,
3737- feeds.failed_n_times AS feed_failed
3636+ feeds.next_check AS next_check,
3737+ feeds.repeated_failures AS repeated_failures
3838FROM
3939 subscriptions
4040LEFT JOIN
···5454SELECT
5555 feeds.id,
5656 feeds.link,
5757- feeds.skip_n_times,
5858- feeds.failed_n_times,
5757+ feeds.next_check,
5858+ feeds.repeated_failures,
5959 count(subscriptions.user_id)
6060FROM
6161 feeds
+1-1
src/eater/ui/main_ui.gleam
···207207 UserClickedUnsubscribe(rss.Location)
208208 UnsubscribedInBackend(Result(rss.Location, Nil))
209209 UserSubmittedSubscription(Result(uri.Uri, Form(uri.Uri)))
210210- SubscribedInBackend(Result(rss.Location, Nil))
211210 BackendReturnedFeed(Result(rss.Location, Nil))
211211+ SubscribedInBackend(Result(rss.Location, backend.NewSubscriptionError))
212212}
213213214214/// describe a message using structured data