Compare commits

..

15 Commits

Author SHA1 Message Date
Bel LaPointe
1fed2d648f stub feeds http test 2025-04-27 12:06:40 -06:00
Bel LaPointe
a372df64a5 shouldexecute checks deleted 2025-04-27 12:04:43 -06:00
Bel LaPointe
ab396d1833 move cron parse into feed.ShouldExecute 2025-04-27 12:04:11 -06:00
Bel LaPointe
a097814a62 feeds.versions have a pattern 2025-04-27 11:58:00 -06:00
Bel LaPointe
18fd8dfac5 change to feed.Foo methods instead of passing in feed to package func 2025-04-27 11:55:00 -06:00
Bel LaPointe
ce02422b1d ooo i should put more than just DB in feeds huh 2025-04-27 11:51:04 -06:00
Bel LaPointe
ec1f0e007a debatably better 2025-04-27 11:49:57 -06:00
Bel LaPointe
19b6d180e7 feeds from empty struct 2025-04-27 11:49:20 -06:00
Bel LaPointe
e54c7a76f9 gonna swap from feeds.Feeds 2025-04-27 11:46:20 -06:00
Bel LaPointe
537eaf9801 cron testone tries nonempty 2025-04-27 11:33:59 -06:00
Bel LaPointe
baa97ab62d cron.One.feeds.FOrEach 2025-04-27 11:28:28 -06:00
Bel LaPointe
f57408d003 feeds.ForEach 2025-04-27 11:27:47 -06:00
Bel LaPointe
05587ac28e need a way to interate 2025-04-27 11:21:33 -06:00
Bel LaPointe
7f4f760407 test multi-executions 2025-04-27 11:19:26 -06:00
Bel LaPointe
7f97eecbca feeds.Executed 2025-04-27 11:18:17 -06:00
9 changed files with 445 additions and 220 deletions

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.31.0 // indirect
modernc.org/libc v1.62.1 // indirect

2
go.sum
View File

@@ -10,6 +10,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=

View File

@@ -2,7 +2,7 @@ package cron
import (
"context"
"io"
"fmt"
"show-rss/src/feeds"
"time"
)
@@ -24,11 +24,24 @@ func Main(ctx context.Context) error {
}
func One(ctx context.Context) error {
feeds, err := feeds.New(ctx)
if err != nil {
return err
return feeds.ForEach(ctx, func(feed feeds.Feed) error {
if err := one(ctx, feed); err != nil {
return fmt.Errorf("failed to cron %s (%+v): %w", feed.Entry.ID, feed.Version, err)
}
_ = feeds
return io.EOF
return nil
})
}
func one(ctx context.Context, feed feeds.Feed) error {
if should, err := feed.ShouldExecute(); err != nil {
return err
} else if !should {
return nil
}
return fmt.Errorf("should GET %s", feed.Version.URL)
return fmt.Errorf("should parse %s", feed.Version.URL)
return feed.Executed(ctx)
}

View File

@@ -2,8 +2,13 @@ package cron_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"show-rss/src/cmd/cron"
"show-rss/src/db"
"show-rss/src/feeds"
"slices"
"strconv"
"testing"
"time"
@@ -13,8 +18,39 @@ func TestOne(t *testing.T) {
ctx, can := context.WithTimeout(context.Background(), 5*time.Second)
defer can()
t.Run("same ctx", func(t *testing.T) {
for name, aCtx := range map[string]func() context.Context{
"empty": func() context.Context {
return db.Test(t, ctx)
},
"feeds": func() context.Context {
gets := []string{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gets = append(gets, r.URL.String())
t.Logf("%s", gets[len(gets)-1])
}))
t.Cleanup(s.Close)
t.Cleanup(func() {
slices.Sort(gets)
if len(gets) != 2+2+2 { // id=1+id=2 for each of 2 unrecycled ctx, id=1+id=2 for one across shared ctx
t.Errorf("didn't call urls exactly twice: %+v", gets)
}
})
ctx := db.Test(t, ctx)
for i := 0; i < 2; i++ {
if _, err := feeds.Insert(ctx, fmt.Sprintf("%s?idx=%d", s.URL, i), "* * * * *", "matches"); err != nil {
t.Fatal(err)
}
}
return ctx
},
} {
name := name
aCtx := aCtx
t.Run(name, func(t *testing.T) {
t.Run("same ctx", func(t *testing.T) {
ctx := aCtx()
for i := 0; i < 2; i++ {
t.Run(strconv.Itoa(i), func(t *testing.T) {
if err := cron.One(ctx); err != nil && ctx.Err() == nil {
@@ -27,10 +63,13 @@ func TestOne(t *testing.T) {
t.Run("new ctx", func(t *testing.T) {
for i := 0; i < 2; i++ {
t.Run(strconv.Itoa(i), func(t *testing.T) {
if err := cron.One(db.Test(t, ctx)); err != nil && ctx.Err() == nil {
ctx := aCtx()
if err := cron.One(ctx); err != nil && ctx.Err() == nil {
t.Fatalf("failed %d: %v", i, err)
}
})
}
})
})
}
}

View File

@@ -11,10 +11,234 @@ import (
"github.com/google/uuid"
)
type Feeds struct{}
type (
Feed struct {
Entry Entry
Version Version
Execution Execution
}
func New(ctx context.Context) (Feeds, error) {
return Feeds{}, initDB(ctx)
Entry struct {
ID string
Created time.Time
Updated time.Time
Deleted time.Time
}
Version struct {
Created time.Time
URL string
Cron string
Pattern string
}
Execution struct {
Executed time.Time
Version time.Time
}
)
func ForEach(ctx context.Context, cb func(Feed) error) error {
if err := initDB(ctx); err != nil {
return err
}
type id struct {
ID string `json:"id"`
}
ids, err := db.Query[id](ctx, `SELECT id FROM "feed.entries" WHERE deleted_at IS NULL`)
if err != nil {
return err
}
for _, id := range ids {
feed, err := Get(ctx, id.ID)
if err != nil {
return err
} else if err := cb(feed); err != nil {
return err
}
}
return nil
}
func Get(ctx context.Context, id string) (Feed, error) {
if err := initDB(ctx); err != nil {
return Feed{}, err
}
return db.QueryOne[Feed](ctx, `
WITH
entry AS (
SELECT
id AS ID,
created_at AS Created,
updated_at AS Updated,
deleted_at AS Deleted
FROM "feed.entries"
WHERE id = ?
),
execution AS (
SELECT
executed_at AS Executed,
versions_created_at AS Version
FROM "feed.executions"
WHERE entries_id = ?
ORDER BY executed DESC
LIMIT 1
)
SELECT
entry.ID AS "Entry.ID",
entry.Created AS "Entry.Created",
entry.Updated AS "Entry.Updated",
entry.Deleted AS "Entry.Deleted",
versions.created_at AS "Version.Created",
versions.url AS "Version.URL",
versions.cron AS "Version.Cron",
versions.pattern AS "Version.Pattern",
(
SELECT executed_at
FROM "feed.executions"
WHERE entries_id = entry.ID
ORDER BY executed_at DESC
LIMIT 1
) AS "Execution.Executed",
(
SELECT versions_created_at
FROM "feed.executions"
WHERE entries_id = entry.ID
ORDER BY executed_at DESC
LIMIT 1
) AS "Execution.Version"
FROM entry
JOIN "feed.versions" version_entries_id ON
version_entries_id.entries_id=entry.ID
JOIN "feed.versions" versions ON
versions.created_at=entry.Updated
`, id, id)
}
func oldGet(ctx context.Context, id string) (Feed, error) {
if err := initDB(ctx); err != nil {
return Feed{}, err
}
entry, err := getEntry(ctx, id)
if err != nil {
return Feed{}, err
}
version, err := db.QueryOne[Version](ctx, `
SELECT
"feed.current_versions".versions_created_at AS Created,
"feed.current_versions".url AS URL,
"feed.current_versions".cron AS Cron,
"feed.current_versions".pattern AS Pattern
FROM
"feed.current_versions"
JOIN
"feed.versions" versions_a ON
"feed.current_versions".entries_id=versions_a.entries_id
JOIN
"feed.versions" versions_b ON
"feed.current_versions".versions_created_at=versions_b.created_at
WHERE
"feed.current_versions".entries_id = ?
`, id)
if err != nil {
return Feed{}, err
}
execution, err := db.QueryOne[Execution](ctx, `
SELECT
"feed.executed_at" AS Executed,
"feed.versions_created_at" AS VersionsCreated
FROM
"feed.executions"
WHERE
"feed.executions".entries_id = ?
ORDER BY "feed.executions".executed_at DESC
`, id)
if err != nil {
return Feed{}, err
}
return Feed{}, fmt.Errorf("%+v, %+v, %+v", entry, version, execution)
}
func (feed Feed) Executed(ctx context.Context) error {
if err := initDB(ctx); err != nil {
return err
}
id := feed.Entry.ID
version := feed.Version.Created
now := time.Now()
return db.Exec(ctx, `
INSERT INTO "feed.executions" (
entries_id,
versions_created_at,
executed_at
) VALUES (?, ?, ?);
`,
id, version, now,
)
}
func Insert(ctx context.Context, url, cron, pattern string) (string, error) {
if err := initDB(ctx); err != nil {
return "", err
}
now := time.Now()
id := uuid.New().String()
return id, db.Exec(ctx, `
BEGIN;
INSERT INTO "feed.entries" (
id,
created_at,
updated_at
) VALUES ($1, $2, $3);
INSERT INTO "feed.versions" (
entries_id,
created_at,
url,
cron,
pattern
) VALUES ($4, $5, $6, $7, $8);
COMMIT;
`,
id, now, now,
id, now, url, cron, pattern,
)
}
func (feed Feed) Update(ctx context.Context, url, cron, pattern *string) error {
return io.EOF
}
func (feed Feed) Delete(ctx context.Context) error {
return io.EOF
}
func getEntry(ctx context.Context, id string) (Entry, error) {
if err := initDB(ctx); err != nil {
return Entry{}, err
}
return db.QueryOne[Entry](ctx, `
SELECT
id AS ID,
created_at AS Created,
updated_at AS Updated,
deleted_at AS Deleted
FROM
"feed.entries"
WHERE
id = ?
`, id)
}
func initDB(ctx context.Context) error {
@@ -51,6 +275,7 @@ func initDB(ctx context.Context) error {
)`,
`ALTER TABLE "feed.versions" ADD COLUMN url TEXT NOT NULL`,
`ALTER TABLE "feed.versions" ADD COLUMN cron TEXT NOT NULL DEFAULT '0 0 * * *'`,
`ALTER TABLE "feed.versions" ADD COLUMN pattern TEXT NOT NULL DEFAULT ''`,
`CREATE TABLE "feed.executions" (
entries_id TEXT,
@@ -72,168 +297,3 @@ func initDB(ctx context.Context) error {
return nil
}
type (
Feed struct {
Entry Entry
Version Version
Execution Execution
}
Entry struct {
ID string
Created time.Time
Updated time.Time
Deleted time.Time
}
Version struct {
Created time.Time
URL string
Cron string
}
Execution struct {
Executed time.Time
Version time.Time
}
)
func (f *Feeds) Get(ctx context.Context, id string) (Feed, error) {
return db.QueryOne[Feed](ctx, `
WITH
entry AS (
SELECT
id AS ID,
created_at AS Created,
updated_at AS Updated,
deleted_at AS Deleted
FROM "feed.entries"
WHERE id = ?
),
execution AS (
SELECT
executed_at AS Executed,
versions_created_at AS Version
FROM "feed.executions"
WHERE entries_id = ?
ORDER BY executed DESC
LIMIT 1
)
SELECT
entry.ID AS "Entry.ID",
entry.Created AS "Entry.Created",
entry.Updated AS "Entry.Updated",
entry.Deleted AS "Entry.Deleted",
versions.created_at AS "Version.Created",
versions.url AS "Version.URL",
versions.cron AS "Version.Cron",
(
SELECT executed_at
FROM "feed.executions"
WHERE entries_id = entry.ID
ORDER BY executed_at DESC
LIMIT 1
) AS "Execution.Executed",
(
SELECT versions_created_at
FROM "feed.executions"
WHERE entries_id = entry.ID
ORDER BY executed_at DESC
LIMIT 1
) AS "Execution.Version"
FROM entry
JOIN "feed.versions" version_entries_id ON
version_entries_id.entries_id=entry.ID
JOIN "feed.versions" versions ON
versions.created_at=entry.Updated
`, id, id)
}
func (f *Feeds) oldGet(ctx context.Context, id string) (Feed, error) {
entry, err := f.getEntry(ctx, id)
if err != nil {
return Feed{}, err
}
version, err := db.QueryOne[Version](ctx, `
SELECT
"feed.current_versions".versions_created_at AS Created,
"feed.current_versions" AS URL,
"feed.current_versions" AS Cron
FROM
"feed.current_versions"
JOIN
"feed.versions" versions_a ON
"feed.current_versions".entries_id=versions_a.entries_id
JOIN
"feed.versions" versions_b ON
"feed.current_versions".versions_created_at=versions_b.created_at
WHERE
"feed.current_versions".entries_id = ?
`, id)
if err != nil {
return Feed{}, err
}
execution, err := db.QueryOne[Execution](ctx, `
SELECT
"feed.executed_at" AS Executed,
"feed.versions_created_at" AS VersionsCreated
FROM
"feed.executions"
WHERE
"feed.executions".entries_id = ?
ORDER BY "feed.executions".executed_at DESC
`, id)
if err != nil {
return Feed{}, err
}
return Feed{}, fmt.Errorf("%+v, %+v, %+v", entry, version, execution)
}
func (f *Feeds) Insert(ctx context.Context, url, cron string) (string, error) {
now := time.Now()
id := uuid.New().String()
return id, db.Exec(ctx, `
BEGIN;
INSERT INTO "feed.entries" (
id,
created_at,
updated_at
) VALUES ($1, $2, $3);
INSERT INTO "feed.versions" (
entries_id,
created_at,
url,
cron
) VALUES ($4, $5, $6, $7);
COMMIT;
`,
id, now, now,
id, now, url, cron,
)
}
func (f *Feeds) Update(ctx context.Context, id string, url, cron *string) error {
return io.EOF
}
func (f *Feeds) Delete(ctx context.Context, id string) error {
return io.EOF
}
func (f *Feeds) getEntry(ctx context.Context, id string) (Entry, error) {
return db.QueryOne[Entry](ctx, `
SELECT
id AS ID,
created_at AS Created,
updated_at AS Updated,
deleted_at AS Deleted
FROM
"feed.entries"
WHERE
id = ?
`, id)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"show-rss/src/db"
"show-rss/src/feeds"
"strconv"
"testing"
"time"
)
@@ -13,41 +12,15 @@ func TestFeeds(t *testing.T) {
ctx, can := context.WithTimeout(context.Background(), 5*time.Second)
defer can()
t.Run("same ctx", func(t *testing.T) {
ctx := db.Test(t, ctx)
for i := 0; i < 2; i++ {
t.Run(strconv.Itoa(i), func(t *testing.T) {
if _, err := feeds.New(ctx); err != nil && ctx.Err() == nil {
t.Fatalf("failed %d: %v", i, err)
}
})
}
})
t.Run("new ctx", func(t *testing.T) {
for i := 0; i < 2; i++ {
t.Run(strconv.Itoa(i), func(t *testing.T) {
if _, err := feeds.New(db.Test(t, ctx)); err != nil && ctx.Err() == nil {
t.Fatalf("failed %d: %v", i, err)
}
})
}
})
t.Run("crud", func(t *testing.T) {
ctx := db.Test(t, ctx)
f, err := feeds.New(ctx)
if err != nil && ctx.Err() == nil {
t.Fatalf("failed: %v", err)
}
id, err := f.Insert(ctx, "url", "cron")
id, err := feeds.Insert(ctx, "url", "cron", "pattern")
if err != nil {
t.Fatal("cannot insert:", err)
}
got, err := f.Get(ctx, id)
got, err := feeds.Get(ctx, id)
if err != nil {
t.Fatal("cannot get:", err)
}
@@ -75,6 +48,9 @@ func TestFeeds(t *testing.T) {
if got.Version.Cron != "cron" {
t.Error("no version.cron")
}
if got.Version.Pattern != "pattern" {
t.Error("bad version.pattern")
}
if !got.Execution.Executed.IsZero() {
t.Error("execution.executed")
@@ -82,5 +58,50 @@ func TestFeeds(t *testing.T) {
if !got.Execution.Version.IsZero() {
t.Error("execution.version")
}
if err := got.Executed(ctx); err != nil {
t.Fatal("cannot executed:", err)
}
got2, err := feeds.Get(ctx, id)
if err != nil {
t.Fatal("cannot get w executed:", err)
}
t.Logf("%+v", got2)
if got2.Execution.Executed.IsZero() {
t.Error("no execution.executed")
}
if got2.Execution.Version != got.Version.Created {
t.Errorf("bad execution.version: expected %v but got %v (difference of %v)", got.Version.Created, got2.Execution.Version, got2.Execution.Version.Sub(got.Execution.Version))
}
got2.Execution = got.Execution
if got != got2 {
t.Errorf("changes after execution: was \n\t%+v but now \n\t%+v", got, got2)
}
if err := got.Executed(ctx); err != nil {
t.Fatal("cannot executed again:", err)
}
got3, err := feeds.Get(ctx, id)
if err != nil {
t.Fatal("cannot get w executed again:", err)
} else if got2.Execution == got3.Execution {
t.Errorf("getting after second execution returned first execution")
}
n := 0
if err := feeds.ForEach(ctx, func(feed feeds.Feed) error {
n += 1
if feed != got3 {
t.Errorf("for each yielded difference than last get")
}
return nil
}); err != nil {
t.Error(err)
} else if n == 0 {
t.Errorf("for each didnt hit known get")
}
})
}

35
src/feeds/http.go Normal file
View File

@@ -0,0 +1,35 @@
package feeds
import (
"context"
"fmt"
"io"
"time"
"github.com/robfig/cron/v3"
)
func (feed Feed) ShouldExecute() (bool, error) {
if feed.Entry.Deleted.IsZero() {
return false, nil
}
schedule, err := cron.NewParser(
cron.SecondOptional |
cron.Minute |
cron.Hour |
cron.Dom |
cron.Month |
cron.Dow |
cron.Descriptor,
).Parse(feed.Version.Cron)
if err != nil {
return false, fmt.Errorf("illegal cron %q", feed.Version.Cron)
}
next := schedule.Next(feed.Execution.Executed)
return time.Now().Before(next), nil
}
func (feed Feed) Fetch(ctx context.Context) (Items, error) {
return nil, io.EOF
}

49
src/feeds/http_test.go Normal file
View File

@@ -0,0 +1,49 @@
package feeds_test
import (
"context"
"net/http"
"net/http/httptest"
"show-rss/src/feeds"
"testing"
"time"
)
func TestFeedFetch(t *testing.T) {
ctx := context.Background()
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Logf("%s", r.URL.String())
}))
t.Cleanup(s.Close)
created := time.Now().Add(-4 * time.Second)
feed := feeds.Feed{
Entry: feeds.Entry{
ID: "id",
Created: created,
Updated: created,
Deleted: time.Time{},
},
Version: feeds.Version{
Created: created,
URL: s.URL,
Cron: "* * * * *",
Pattern: "matches",
},
Execution: feeds.Execution{
Executed: created.Add(-2 * time.Second),
Version: created,
},
}
items, err := feed.Fetch(ctx)
if err != nil {
t.Fatalf("failed fetch: %v", err)
}
if len(items) != 2 {
t.Fatalf("expected 2 items but got %+v", items)
}
t.Errorf("check items")
}

5
src/feeds/item.go Normal file
View File

@@ -0,0 +1,5 @@
package feeds
type Items []Item
type Item struct{}