Skip to content

Commit 9eac3bb

Browse files
committed
Merge: main -> env/prod (#240 参拝時のGitHubイベント取得を100件から300件に広げる)
2 parents fbf2837 + 19e4fcd commit 9eac3bb

3 files changed

Lines changed: 311 additions & 25 deletions

File tree

‎app/functions-go/sanpai.go‎

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,82 @@ type feedItem struct {
129129
Event githubEvent
130130
}
131131

132-
// fetchGitHubFeed は GitHub Events API から公開アクティビティを取得する
133-
// (Node版 get_feed と同一のURL・クエリパラメータ)。
134-
func fetchGitHubFeed(ctx context.Context, username string) ([]feedItem, error) {
132+
// firestoreBatchMaxWrites は Firestore の書き込みバッチ1回あたりの上限。
133+
const firestoreBatchMaxWrites = 500
134+
135+
// githubFeedPerPage は1ページあたりの取得件数(Events APIの上限)。
136+
const githubFeedPerPage = 100
137+
138+
// githubFeedMaxPages は遡るページ数の上限。Events API は最大300件(100件×3ページ)
139+
// までしか返さないため、それ以上は要求しても無駄。
140+
const githubFeedMaxPages = 3
141+
142+
// fetchGitHubFeed は GitHub Events API から公開アクティビティを取得する。
143+
//
144+
// 以前は1ページ(100件)だけ取って終わりだったため、前回の参拝から公開イベントが
145+
// 100件を超えると超過分を永久に取りこぼしていた(90日を過ぎると GitHub からも
146+
// 返らなくなるため、後から拾い直せない)。since(前回の参拝時刻)まで遡って取る。
147+
//
148+
// 取得済みの範囲に入ったページで打ち切るので、普段の参拝は1リクエストのまま。
149+
// 全ページ舐めるのは初回参拝や、久しぶりの参拝で100件を超えている人だけ。
150+
func fetchGitHubFeed(ctx context.Context, username string, since time.Time) ([]feedItem, error) {
151+
var all []feedItem
152+
// ページの取得中に新しいイベントが増えると窓がずれ、同じイベントが2つの
153+
// ページに載ることがある。重複したまま返すと、同一バッチ内で同じドキュメントへ
154+
// 2回書くことになり Firestore に弾かれる(参拝そのものが失敗する)うえ、
155+
// ポイントと能力値も二重計上になる。1ページだけ取っていた頃は GitHub が
156+
// ページ内のID一意を保証していたので起きなかった。
157+
seen := make(map[string]bool)
158+
for page := 1; page <= githubFeedMaxPages; page++ {
159+
items, err := fetchGitHubFeedPage(ctx, username, page)
160+
if err != nil {
161+
// 途中のページで失敗したら参拝ごと失敗させる。ここで取れた分だけで
162+
// 進めると last_sanpai が進んでしまい、取れなかったイベントを
163+
// 二度と拾えなくなる(取りこぼしを止めるための変更なので本末転倒)。
164+
// 失敗しても last_sanpai は書き換えないため、やり直せる。
165+
return nil, err
166+
}
167+
for _, it := range items {
168+
if it.Event.ID != "" && seen[it.Event.ID] {
169+
continue
170+
}
171+
seen[it.Event.ID] = true
172+
all = append(all, it)
173+
}
174+
// 最終ページ(埋まっていない)ならこれ以上は無い。
175+
if len(items) < githubFeedPerPage {
176+
break
177+
}
178+
// 集計済みの時刻まで遡れたら十分。
179+
if reachedSince(items, since) {
180+
break
181+
}
182+
}
183+
return all, nil
184+
}
185+
186+
// reachedSince はページ内に since 以前のイベントが含まれるかを返す(純関数)。
187+
// Events API は新しい順に返すので、1件でも含まれていればそれ以上遡る必要はない。
188+
func reachedSince(items []feedItem, since time.Time) bool {
189+
for _, it := range items {
190+
t, err := time.Parse(time.RFC3339, it.Event.CreatedAt)
191+
if err != nil {
192+
continue
193+
}
194+
if !t.After(since) {
195+
return true
196+
}
197+
}
198+
return false
199+
}
200+
201+
func fetchGitHubFeedPage(ctx context.Context, username string, page int) ([]feedItem, error) {
135202
clientID := os.Getenv("GITHUB_CLIENT_ID")
136203
clientSecret := os.Getenv("GITHUB_CLIENT_SECRET")
137204

138205
reqURL := fmt.Sprintf(
139-
"%s/users/%s/events/public?per_page=100",
140-
githubAPIBaseURL, url.PathEscape(username),
206+
"%s/users/%s/events/public?per_page=%d&page=%d",
207+
githubAPIBaseURL, url.PathEscape(username), githubFeedPerPage, page,
141208
)
142209
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
143210
if err != nil {
@@ -323,19 +390,20 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli
323390
}
324391
}
325392

326-
feed, err := fetchGitHubFeed(ctx, userData.ScreenName)
327-
if err != nil {
328-
// Node版はGitHub取得失敗時に例外化し外側catchで "missing server error." になる。
329-
return err
330-
}
331-
393+
// 前回の参拝時刻。ここまで遡って取得し、ここより新しいイベントだけを集計する。
332394
var since time.Time
333395
if hasLastSanpai {
334396
since = userData.LastSanpai
335397
} else {
336398
since, _ = time.Parse(time.RFC3339, "2008-04-01T00:00:00Z")
337399
}
338400

401+
feed, err := fetchGitHubFeed(ctx, userData.ScreenName, since)
402+
if err != nil {
403+
// Node版はGitHub取得失敗時に例外化し外側catchで "missing server error." になる。
404+
return err
405+
}
406+
339407
var splited []feedItem
340408
for _, it := range feed {
341409
created, err := time.Parse(time.RFC3339, it.Event.CreatedAt)
@@ -360,19 +428,29 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli
360428
}
361429

362430
// アクティビティ反映
363-
batch := client.Batch()
431+
// Firestore のバッチは1回あたり500件までなので分割して書く。
432+
// 1ページ(100件)しか取っていなかった頃は上限に当たりようがなかったが、
433+
// 300件まで遡るようになった(#239)ので余裕が減っている。ページ数を増やしても
434+
// ここが破綻しないよう、件数に依らない形にしておく。
364435
activityColl := userRef.Collection("github_activities")
365-
for _, it := range splited {
366-
docRef := activityColl.Doc(it.Event.ID)
367-
batch.Set(docRef, map[string]interface{}{
368-
"id": it.Event.ID,
369-
"type": it.Event.Type,
370-
"created_at": it.Event.CreatedAt,
371-
"raw": string(it.Raw),
372-
})
373-
}
374-
if _, err := batch.Commit(ctx); err != nil {
375-
return err
436+
for start := 0; start < len(splited); start += firestoreBatchMaxWrites {
437+
end := start + firestoreBatchMaxWrites
438+
if end > len(splited) {
439+
end = len(splited)
440+
}
441+
batch := client.Batch()
442+
for _, it := range splited[start:end] {
443+
docRef := activityColl.Doc(it.Event.ID)
444+
batch.Set(docRef, map[string]interface{}{
445+
"id": it.Event.ID,
446+
"type": it.Event.Type,
447+
"created_at": it.Event.CreatedAt,
448+
"raw": string(it.Raw),
449+
})
450+
}
451+
if _, err := batch.Commit(ctx); err != nil {
452+
return err
453+
}
376454
}
377455

378456
// 意図的な省略: Node版にはここに「2022/1/1〜1/3はポイント3倍」という

‎app/functions-go/sanpai_test.go‎

Lines changed: 175 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"os"
1010
"strings"
11+
"sync/atomic"
1112
"testing"
1213
"time"
1314

@@ -102,7 +103,7 @@ func TestFetchGitHubFeed_SendsBasicAuthHeader(t *testing.T) {
102103
defer srv.Close()
103104
withMockGitHub(t, srv)
104105

105-
if _, err := fetchGitHubFeed(context.Background(), "octocat"); err != nil {
106+
if _, err := fetchGitHubFeed(context.Background(), "octocat", time.Time{}); err != nil {
106107
t.Fatalf("fetchGitHubFeed: %v", err)
107108
}
108109
}
@@ -122,7 +123,7 @@ func TestFetchGitHubFeed_NoCredentials(t *testing.T) {
122123
defer srv.Close()
123124
withMockGitHub(t, srv)
124125

125-
if _, err := fetchGitHubFeed(context.Background(), "octocat"); err != nil {
126+
if _, err := fetchGitHubFeed(context.Background(), "octocat", time.Time{}); err != nil {
126127
t.Fatalf("fetchGitHubFeed: %v", err)
127128
}
128129
}
@@ -356,3 +357,175 @@ func TestSanpaiHandler_MissingAuthorizationHeader(t *testing.T) {
356357
t.Errorf("status = %d, want 401", rec.Code)
357358
}
358359
}
360+
361+
// events を新しい順に n 件返すモックページを組み立てる(created_at は base から1分ずつ遡る)。
362+
func mockEventsPage(base time.Time, offset, n int) string {
363+
items := make([]string, 0, n)
364+
for i := 0; i < n; i++ {
365+
at := base.Add(-time.Duration(offset+i) * time.Minute).UTC().Format(time.RFC3339)
366+
items = append(items, fmt.Sprintf(
367+
`{"id":"e%d","type":"PushEvent","created_at":%q,"repo":{"name":"o/r"},"payload":{}}`,
368+
offset+i, at))
369+
}
370+
return "[" + strings.Join(items, ",") + "]"
371+
}
372+
373+
// 普段の参拝(前回から100件も動いていない)では1ページで打ち切ること。
374+
// ここが増えると全ユーザーのGitHub API呼び出しが毎回3倍になる。
375+
func TestFetchGitHubFeed_StopsAtFirstPageWhenCaughtUp(t *testing.T) {
376+
now := time.Now()
377+
since := now.Add(-30 * time.Minute) // 30件目より新しい
378+
var pages int32
379+
380+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
381+
atomic.AddInt32(&pages, 1)
382+
w.Header().Set("Content-Type", "application/json")
383+
// 1ページ丸ごと(100件)返す。ただし since 以前の分が含まれる。
384+
_, _ = w.Write([]byte(mockEventsPage(now, 0, githubFeedPerPage)))
385+
}))
386+
defer srv.Close()
387+
withMockGitHub(t, srv)
388+
389+
items, err := fetchGitHubFeed(context.Background(), "octocat", since)
390+
if err != nil {
391+
t.Fatalf("fetchGitHubFeed: %v", err)
392+
}
393+
if got := atomic.LoadInt32(&pages); got != 1 {
394+
t.Errorf("取得済みの範囲に達したら打ち切るべき: %d ページ取得した", got)
395+
}
396+
if len(items) != githubFeedPerPage {
397+
t.Errorf("件数 = %d, want %d", len(items), githubFeedPerPage)
398+
}
399+
}
400+
401+
// 100件を超えて動いている場合は上限(3ページ=300件)まで遡ること。
402+
// これが無いと超過分を永久に取りこぼす(#239)。
403+
func TestFetchGitHubFeed_PaginatesUpToMaxPages(t *testing.T) {
404+
now := time.Now()
405+
since := now.Add(-365 * 24 * time.Hour) // どのページにも到達しない
406+
var pages int32
407+
408+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
409+
p := atomic.AddInt32(&pages, 1)
410+
if got := r.URL.Query().Get("page"); got != fmt.Sprint(p) {
411+
t.Errorf("page パラメータ = %q, want %d", got, p)
412+
}
413+
w.Header().Set("Content-Type", "application/json")
414+
_, _ = w.Write([]byte(mockEventsPage(now, int(p-1)*githubFeedPerPage, githubFeedPerPage)))
415+
}))
416+
defer srv.Close()
417+
withMockGitHub(t, srv)
418+
419+
items, err := fetchGitHubFeed(context.Background(), "octocat", since)
420+
if err != nil {
421+
t.Fatalf("fetchGitHubFeed: %v", err)
422+
}
423+
if got := atomic.LoadInt32(&pages); got != githubFeedMaxPages {
424+
t.Errorf("ページ数 = %d, want %d (Events APIの上限)", got, githubFeedMaxPages)
425+
}
426+
want := githubFeedPerPage * githubFeedMaxPages
427+
if len(items) != want {
428+
t.Errorf("件数 = %d, want %d", len(items), want)
429+
}
430+
}
431+
432+
// 埋まっていないページが来たらそこで終わり(存在しないページを叩かない)。
433+
func TestFetchGitHubFeed_StopsOnShortPage(t *testing.T) {
434+
now := time.Now()
435+
var pages int32
436+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
437+
p := atomic.AddInt32(&pages, 1)
438+
w.Header().Set("Content-Type", "application/json")
439+
if p == 1 {
440+
_, _ = w.Write([]byte(mockEventsPage(now, 0, githubFeedPerPage)))
441+
return
442+
}
443+
_, _ = w.Write([]byte(mockEventsPage(now, githubFeedPerPage, 3)))
444+
}))
445+
defer srv.Close()
446+
withMockGitHub(t, srv)
447+
448+
items, err := fetchGitHubFeed(context.Background(), "octocat", time.Time{})
449+
if err != nil {
450+
t.Fatalf("fetchGitHubFeed: %v", err)
451+
}
452+
if got := atomic.LoadInt32(&pages); got != 2 {
453+
t.Errorf("ページ数 = %d, want 2 (2ページ目が埋まっていないので打ち切る)", got)
454+
}
455+
if len(items) != githubFeedPerPage+3 {
456+
t.Errorf("件数 = %d, want %d", len(items), githubFeedPerPage+3)
457+
}
458+
}
459+
460+
func TestReachedSince(t *testing.T) {
461+
now := time.Now()
462+
items := []feedItem{
463+
{Event: githubEvent{CreatedAt: now.Add(-1 * time.Minute).UTC().Format(time.RFC3339)}},
464+
{Event: githubEvent{CreatedAt: now.Add(-10 * time.Minute).UTC().Format(time.RFC3339)}},
465+
{Event: githubEvent{CreatedAt: "壊れた値"}},
466+
}
467+
if !reachedSince(items, now.Add(-5*time.Minute)) {
468+
t.Errorf("since 以前の要素があるので true のはず")
469+
}
470+
if reachedSince(items, now.Add(-60*time.Minute)) {
471+
t.Errorf("すべて since より新しいので false のはず")
472+
}
473+
if reachedSince(nil, now) {
474+
t.Errorf("空なら false")
475+
}
476+
}
477+
478+
// ページの取得中に新しいイベントが増えると窓がずれ、同じイベントが2つのページに
479+
// 載ることがある。重複したまま返すと、同一バッチ内で同じドキュメントへ2回書く
480+
// ことになり Firestore に弾かれる(参拝そのものが失敗する)うえ、ポイントと
481+
// 能力値も二重計上になる。
482+
func TestFetchGitHubFeed_DedupesAcrossPages(t *testing.T) {
483+
now := time.Now()
484+
var pages int32
485+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
486+
p := atomic.AddInt32(&pages, 1)
487+
w.Header().Set("Content-Type", "application/json")
488+
// 2ページとも同じ offset から返す = 全件重複する状況を作る。
489+
_, _ = w.Write([]byte(mockEventsPage(now, 0, githubFeedPerPage)))
490+
_ = p
491+
}))
492+
defer srv.Close()
493+
withMockGitHub(t, srv)
494+
495+
items, err := fetchGitHubFeed(context.Background(), "octocat", time.Time{})
496+
if err != nil {
497+
t.Fatalf("fetchGitHubFeed: %v", err)
498+
}
499+
if len(items) != githubFeedPerPage {
500+
t.Errorf("重複を除いた件数 = %d, want %d", len(items), githubFeedPerPage)
501+
}
502+
seen := map[string]bool{}
503+
for _, it := range items {
504+
if seen[it.Event.ID] {
505+
t.Fatalf("重複したイベントIDが残っている: %s", it.Event.ID)
506+
}
507+
seen[it.Event.ID] = true
508+
}
509+
}
510+
511+
// 途中のページで失敗したら参拝ごと失敗させる(部分的に進めると last_sanpai が
512+
// 進んで、取れなかったイベントを二度と拾えなくなる)。
513+
func TestFetchGitHubFeed_FailsWhenLaterPageFails(t *testing.T) {
514+
now := time.Now()
515+
var pages int32
516+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
517+
p := atomic.AddInt32(&pages, 1)
518+
if p == 1 {
519+
w.Header().Set("Content-Type", "application/json")
520+
_, _ = w.Write([]byte(mockEventsPage(now, 0, githubFeedPerPage)))
521+
return
522+
}
523+
w.WriteHeader(http.StatusInternalServerError)
524+
}))
525+
defer srv.Close()
526+
withMockGitHub(t, srv)
527+
528+
if _, err := fetchGitHubFeed(context.Background(), "octocat", time.Time{}); err == nil {
529+
t.Errorf("2ページ目が失敗したらエラーを返すべき(部分的に進めない)")
530+
}
531+
}

0 commit comments

Comments
 (0)